ts-procedures 8.3.0 → 8.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -8
  2. package/agent_config/claude-code/skills/ts-procedures/templates/client.md +3 -3
  3. package/agent_config/claude-code/skills/ts-procedures/templates/hono.md +3 -3
  4. package/agent_config/claude-code/skills/ts-procedures/templates/procedure.md +3 -3
  5. package/agent_config/claude-code/skills/ts-procedures/templates/stream-procedure.md +3 -3
  6. package/build/client/call.js +1 -1
  7. package/build/client/call.js.map +1 -1
  8. package/build/client/index.d.ts +1 -1
  9. package/build/client/index.js +23 -1
  10. package/build/client/index.js.map +1 -1
  11. package/build/client/index.test.js +87 -0
  12. package/build/client/index.test.js.map +1 -1
  13. package/build/client/resolve-options.d.ts +5 -4
  14. package/build/client/resolve-options.js +18 -7
  15. package/build/client/resolve-options.js.map +1 -1
  16. package/build/client/resolve-options.test.js +53 -24
  17. package/build/client/resolve-options.test.js.map +1 -1
  18. package/build/client/stream.js +1 -1
  19. package/build/client/stream.js.map +1 -1
  20. package/build/client/types.d.ts +31 -3
  21. package/build/codegen/__fixtures__/make-envelope.d.ts +41 -0
  22. package/build/codegen/__fixtures__/make-envelope.js +38 -0
  23. package/build/codegen/__fixtures__/make-envelope.js.map +1 -0
  24. package/build/codegen/bin/cli.d.ts +15 -0
  25. package/build/codegen/bin/cli.js +46 -21
  26. package/build/codegen/bin/cli.js.map +1 -1
  27. package/build/codegen/bin/cli.test.js +54 -1
  28. package/build/codegen/bin/cli.test.js.map +1 -1
  29. package/build/codegen/bin/flag-specs.d.ts +10 -0
  30. package/build/codegen/bin/flag-specs.js +62 -0
  31. package/build/codegen/bin/flag-specs.js.map +1 -0
  32. package/build/codegen/bin/flag-specs.test.d.ts +1 -0
  33. package/build/codegen/bin/flag-specs.test.js +35 -0
  34. package/build/codegen/bin/flag-specs.test.js.map +1 -0
  35. package/build/codegen/collect-models.d.ts +48 -0
  36. package/build/codegen/collect-models.js +84 -0
  37. package/build/codegen/collect-models.js.map +1 -0
  38. package/build/codegen/collect-models.test.d.ts +1 -0
  39. package/build/codegen/collect-models.test.js +59 -0
  40. package/build/codegen/collect-models.test.js.map +1 -0
  41. package/build/codegen/emit-client-runtime.js +1 -0
  42. package/build/codegen/emit-client-runtime.js.map +1 -1
  43. package/build/codegen/emit-models.d.ts +26 -0
  44. package/build/codegen/emit-models.js +53 -0
  45. package/build/codegen/emit-models.js.map +1 -0
  46. package/build/codegen/emit-models.test.d.ts +1 -0
  47. package/build/codegen/emit-models.test.js +42 -0
  48. package/build/codegen/emit-models.test.js.map +1 -0
  49. package/build/codegen/emit-scope.d.ts +10 -0
  50. package/build/codegen/emit-scope.js +119 -34
  51. package/build/codegen/emit-scope.js.map +1 -1
  52. package/build/codegen/emit-types.d.ts +26 -1
  53. package/build/codegen/emit-types.js +27 -5
  54. package/build/codegen/emit-types.js.map +1 -1
  55. package/build/codegen/index.d.ts +15 -0
  56. package/build/codegen/index.js +5 -0
  57. package/build/codegen/index.js.map +1 -1
  58. package/build/codegen/model-refs.d.ts +27 -0
  59. package/build/codegen/model-refs.js +49 -0
  60. package/build/codegen/model-refs.js.map +1 -0
  61. package/build/codegen/model-refs.test.d.ts +1 -0
  62. package/build/codegen/model-refs.test.js +33 -0
  63. package/build/codegen/model-refs.test.js.map +1 -0
  64. package/build/codegen/pipeline.d.ts +7 -0
  65. package/build/codegen/pipeline.js +6 -1
  66. package/build/codegen/pipeline.js.map +1 -1
  67. package/build/codegen/schema-walk.d.ts +13 -0
  68. package/build/codegen/schema-walk.js +26 -0
  69. package/build/codegen/schema-walk.js.map +1 -0
  70. package/build/codegen/schema-walk.test.d.ts +1 -0
  71. package/build/codegen/schema-walk.test.js +35 -0
  72. package/build/codegen/schema-walk.test.js.map +1 -0
  73. package/build/codegen/targets/_shared/target-run.d.ts +15 -0
  74. package/build/codegen/targets/ts/run.js +37 -1
  75. package/build/codegen/targets/ts/run.js.map +1 -1
  76. package/build/codegen/targets/ts/shared-models.test.d.ts +1 -0
  77. package/build/codegen/targets/ts/shared-models.test.js +354 -0
  78. package/build/codegen/targets/ts/shared-models.test.js.map +1 -0
  79. package/build/doc-envelope.d.ts +13 -0
  80. package/build/doc-envelope.js +23 -0
  81. package/build/doc-envelope.js.map +1 -0
  82. package/build/doc-envelope.test.d.ts +1 -0
  83. package/build/doc-envelope.test.js +31 -0
  84. package/build/doc-envelope.test.js.map +1 -0
  85. package/build/exports.d.ts +2 -0
  86. package/build/exports.js +1 -0
  87. package/build/exports.js.map +1 -1
  88. package/docs/client-and-codegen.md +163 -0
  89. package/docs/handoffs/ajsc-named-type-collision.md +134 -0
  90. package/docs/handoffs/ajsc-named-type-support.md +181 -0
  91. package/docs/handoffs/shared-models-auto-resolve-response.md +181 -0
  92. package/docs/superpowers/plans/2026-06-05-dx-feedback-round.md +1292 -0
  93. package/docs/superpowers/plans/2026-06-06-shared-models-convention-and-diagnostics.md +659 -0
  94. package/docs/superpowers/specs/2026-06-05-dx-feedback-round-design.md +285 -0
  95. package/package.json +2 -2
  96. package/src/client/call.ts +1 -1
  97. package/src/client/index.test.ts +98 -0
  98. package/src/client/index.ts +32 -1
  99. package/src/client/resolve-options.test.ts +73 -26
  100. package/src/client/resolve-options.ts +23 -9
  101. package/src/client/stream.ts +1 -1
  102. package/src/client/types.ts +34 -3
  103. package/src/codegen/__fixtures__/make-envelope.ts +89 -0
  104. package/src/codegen/bin/cli.test.ts +65 -1
  105. package/src/codegen/bin/cli.ts +51 -22
  106. package/src/codegen/bin/flag-specs.test.ts +38 -0
  107. package/src/codegen/bin/flag-specs.ts +71 -0
  108. package/src/codegen/collect-models.test.ts +68 -0
  109. package/src/codegen/collect-models.ts +125 -0
  110. package/src/codegen/emit-client-runtime.ts +1 -0
  111. package/src/codegen/emit-models.test.ts +48 -0
  112. package/src/codegen/emit-models.ts +63 -0
  113. package/src/codegen/emit-scope.ts +145 -33
  114. package/src/codegen/emit-types.ts +48 -7
  115. package/src/codegen/index.ts +20 -0
  116. package/src/codegen/model-refs.test.ts +37 -0
  117. package/src/codegen/model-refs.ts +57 -0
  118. package/src/codegen/pipeline.ts +13 -1
  119. package/src/codegen/schema-walk.test.ts +37 -0
  120. package/src/codegen/schema-walk.ts +23 -0
  121. package/src/codegen/targets/_shared/target-run.ts +15 -0
  122. package/src/codegen/targets/ts/run.ts +50 -0
  123. package/src/codegen/targets/ts/shared-models.test.ts +391 -0
  124. package/src/doc-envelope.test.ts +35 -0
  125. package/src/doc-envelope.ts +30 -0
  126. package/src/exports.ts +2 -0
@@ -0,0 +1,125 @@
1
+ import type { AnyHttpRouteDoc } from '../implementations/types.js'
2
+ import { toPascalCase } from './naming.js'
3
+ import { walkSubschemas } from './schema-walk.js'
4
+
5
+ /** A schema hoisted out of per-route inlining because it carries a `$id`. */
6
+ export interface CollectedModel {
7
+ /** The schema's `$id` — the stable identity key used for dedup and import mapping. */
8
+ id: string
9
+ /** PascalCase TypeScript identifier for the emitted model (disambiguated on collision). */
10
+ name: string
11
+ /** The model's JSON Schema (first-seen copy). */
12
+ schema: Record<string, unknown>
13
+ }
14
+
15
+ /** Maps a model `$id` to an external import (module + exported name). */
16
+ export interface SharedTypeImport {
17
+ module: string
18
+ name: string
19
+ }
20
+
21
+ /** `$id` → external import. Models with a matching entry are imported rather than generated. */
22
+ export type SharedTypesImportMap = Record<string, SharedTypeImport>
23
+
24
+ /** A {@link CollectedModel} tagged with its external import, if one was configured. */
25
+ export interface ResolvedModel extends CollectedModel {
26
+ import?: SharedTypeImport
27
+ }
28
+
29
+ /** Produces a stable structural key for collision detection (key order does not matter). */
30
+ function structuralKey(value: unknown): string {
31
+ return JSON.stringify(value, (_k, v) => {
32
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
33
+ const sorted: Record<string, unknown> = {}
34
+ for (const k of Object.keys(v as Record<string, unknown>).sort()) {
35
+ sorted[k] = (v as Record<string, unknown>)[k]
36
+ }
37
+ return sorted
38
+ }
39
+ return v
40
+ })
41
+ }
42
+
43
+ /** Derives a base PascalCase name from a schema's `title`, falling back to the last `$id` segment. */
44
+ function deriveBaseName(schema: Record<string, unknown>, id: string): string {
45
+ const title = typeof schema.title === 'string' ? schema.title : undefined
46
+ const source = title ?? id.split(/[/:#?]+/).filter(Boolean).pop() ?? id
47
+ const pascal = toPascalCase(source)
48
+ return pascal.length > 0 ? pascal : 'Model'
49
+ }
50
+
51
+ /**
52
+ * Deep-walks every route's `jsonSchema` and collects each subschema carrying a
53
+ * string `$id` exactly once (deduped by `$id`, first-seen order preserved).
54
+ *
55
+ * - Names are derived from `title` (or the last `$id` segment), PascalCased.
56
+ * - Distinct `$id`s that derive the same name are disambiguated deterministically
57
+ * by first-seen order (`Message`, `Message2`, `Message3`, …).
58
+ * - Two nodes sharing an `$id` but differing structurally throw (the `$id` is a
59
+ * broken identity contract).
60
+ */
61
+ export function collectModels(routes: AnyHttpRouteDoc[]): CollectedModel[] {
62
+ const byId = new Map<string, { schema: Record<string, unknown>; key: string }>()
63
+ const order: string[] = []
64
+
65
+ const visit = (obj: Record<string, unknown>): void => {
66
+ if (typeof obj.$id === 'string') {
67
+ const id = obj.$id
68
+ const key = structuralKey(obj)
69
+ const existing = byId.get(id)
70
+ if (existing) {
71
+ if (existing.key !== key) {
72
+ throw new Error(
73
+ `[ts-procedures-codegen] Conflicting schemas share $id "${id}". A $id must identify a single structural shape; found two divergent definitions.`
74
+ )
75
+ }
76
+ } else {
77
+ byId.set(id, { schema: obj, key })
78
+ order.push(id)
79
+ }
80
+ }
81
+ }
82
+
83
+ for (const route of routes) walkSubschemas(route.jsonSchema, visit)
84
+
85
+ const usedNames = new Map<string, number>()
86
+ return order.map((id) => {
87
+ const { schema } = byId.get(id)!
88
+ const base = deriveBaseName(schema, id)
89
+ const seen = usedNames.get(base) ?? 0
90
+ usedNames.set(base, seen + 1)
91
+ const name = seen === 0 ? base : `${base}${seen + 1}`
92
+ return { id, name, schema }
93
+ })
94
+ }
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
+
104
+ /**
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.
111
+ */
112
+ export function resolveModelImports(
113
+ models: CollectedModel[],
114
+ options: ResolveModelImportsOptions = {},
115
+ ): ResolvedModel[] {
116
+ const { sharedTypesImport = {}, sharedModelsModule } = options
117
+ return models.map((model) => {
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 }
124
+ })
125
+ }
@@ -18,6 +18,7 @@ const TYPES_IMPORT = `import type {
18
18
  ClientInstance,
19
19
  ProcedureCallDefaults,
20
20
  ProcedureCallOptions,
21
+ ClientHeadersInit,
21
22
  CreateClientConfig,
22
23
  RequestMeta,
23
24
  ErrorRegistry,
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { emitModelsFile } from './emit-models.js'
3
+ import type { ResolvedModel } from './collect-models.js'
4
+
5
+ const thread: ResolvedModel = {
6
+ id: 'urn:thread', name: 'Thread',
7
+ schema: { type: 'object', $id: 'urn:thread', title: 'Thread',
8
+ properties: { id: { type: 'string' }, author: { type: 'object', $id: 'urn:msg', title: 'Message', properties: { id: { type: 'string' } } } } } as any,
9
+ }
10
+ const messageGenerated: ResolvedModel = {
11
+ id: 'urn:msg', name: 'Message',
12
+ schema: { type: 'object', $id: 'urn:msg', title: 'Message', properties: { id: { type: 'string' } } } as any,
13
+ }
14
+ const messageImported: ResolvedModel = {
15
+ id: 'urn:msg', name: 'Message', schema: {} as any,
16
+ import: { module: '@shared/schemas', name: 'Message' },
17
+ }
18
+
19
+ describe('emitModelsFile', () => {
20
+ it('returns null when there are no models', async () => {
21
+ expect(await emitModelsFile([], { ajsc: {} })).toBeNull()
22
+ })
23
+
24
+ it('declares a generated model as a named type', async () => {
25
+ const code = await emitModelsFile([messageGenerated], { ajsc: {} })
26
+ expect(code).toMatch(/export type Message =/)
27
+ })
28
+
29
+ it('re-exports a mapped model from the user package', async () => {
30
+ const code = await emitModelsFile([messageImported], { ajsc: {} })
31
+ expect(code).toContain("export { Message } from '@shared/schemas'")
32
+ })
33
+
34
+ it('aliases a re-export when local name differs from the package name', async () => {
35
+ const m: ResolvedModel = { id: 'urn:msg', name: 'ChatMessage', schema: {} as any, import: { module: '@shared/schemas', name: 'Message' } }
36
+ const code = await emitModelsFile([m], { ajsc: {} })
37
+ expect(code).toContain("export { Message as ChatMessage } from '@shared/schemas'")
38
+ })
39
+
40
+ it('a generated model that nests another model references it by name, not inlined', async () => {
41
+ // Thread.author is the Message model — Thread should reference Message, not inline { id }
42
+ const code = await emitModelsFile([thread, messageGenerated], { ajsc: {} })
43
+ // Thread's author property is typed as Message (the model), referenced by bare name
44
+ expect(code).toMatch(/author\??:\s*Message/)
45
+ // and there is no leftover placeholder token
46
+ expect(code).not.toContain('__MODELREF__')
47
+ })
48
+ })
@@ -0,0 +1,63 @@
1
+ import { jsonSchemaToTypeBody, type AjscOptions } from './emit-types.js'
2
+ import { substituteModelRefs } from './model-refs.js'
3
+ import { CODEGEN_HEADER } from './constants.js'
4
+ import type { ResolvedModel } from './collect-models.js'
5
+
6
+ export interface EmitModelsOptions {
7
+ ajsc?: AjscOptions
8
+ }
9
+
10
+ /**
11
+ * Emits the shared `_models.ts` hub: one named TypeScript type per `$id`-bearing
12
+ * schema so routes can reference a single entity type instead of inlining copies.
13
+ *
14
+ * Two kinds of model:
15
+ * - **Imported** (`model.import` set): re-exported from the user's package —
16
+ * `export { <PkgName>[ as <LocalName>] } from '<module>'`.
17
+ * - **Generated** (no `import`): converted from JSON Schema to a TS type via
18
+ * ajsc. Nested OTHER models are first rewritten to `x-named-type` nodes
19
+ * ({@link substituteModelRefs} with `skipSelfId` so the model's own root
20
+ * `$id` is not self-referenced); ajsc (≥7.3.0) then emits each nested model
21
+ * as a bare verbatim reference rather than re-inlining its shape.
22
+ *
23
+ * Declarations are kept FLAT at the top level regardless of `namespaceTypes` —
24
+ * scopes import these by bare name and TS `type` aliases permit forward
25
+ * references, so declaration order does not matter. Re-exports are emitted first,
26
+ * then generated declarations.
27
+ *
28
+ * Returns `null` when there are no models.
29
+ */
30
+ export async function emitModelsFile(
31
+ models: ResolvedModel[],
32
+ opts: EmitModelsOptions,
33
+ ): Promise<string | null> {
34
+ if (models.length === 0) return null
35
+
36
+ const idToName = new Map(models.map((m) => [m.id, m.name]))
37
+
38
+ const reExports: string[] = []
39
+ const declarations: string[] = []
40
+
41
+ for (const model of models) {
42
+ if (model.import) {
43
+ const pkgName = model.import.name
44
+ const localName = model.name
45
+ const clause = pkgName === localName ? pkgName : `${pkgName} as ${localName}`
46
+ reExports.push(`export { ${clause} } from '${model.import.module}'`)
47
+ continue
48
+ }
49
+
50
+ // Generated model: rewrite nested OTHER models to `x-named-type` nodes so
51
+ // ajsc emits them as bare references, then convert to a TS body.
52
+ const substituted = substituteModelRefs(model.schema, idToName, model.id)
53
+ const body = await jsonSchemaToTypeBody(substituted.schema, opts.ajsc)
54
+ if (body == null) continue
55
+ declarations.push(`export type ${model.name} = ${body}`)
56
+ }
57
+
58
+ const blocks: string[] = []
59
+ if (reExports.length > 0) blocks.push(reExports.join('\n'))
60
+ for (const decl of declarations) blocks.push(decl)
61
+
62
+ return [CODEGEN_HEADER, '', blocks.join('\n\n')].join('\n')
63
+ }
@@ -8,12 +8,14 @@ import type {
8
8
  import {
9
9
  jsonSchemaToTypeString,
10
10
  jsonSchemaToTypeBody,
11
+ jsonSchemaToTypeBodyWithRefs,
11
12
  jsonSchemaToExtractedTypes,
12
13
  renameExtractedTypes,
13
14
  extractedDeclName,
14
15
  type AjscOptions,
15
16
  type ExtractedTypeOutput,
16
17
  } from './emit-types.js'
18
+ import { substituteModelRefs } from './model-refs.js'
17
19
  import { CODEGEN_HEADER } from './constants.js'
18
20
  import { toPascalCase } from './naming.js'
19
21
 
@@ -33,6 +35,16 @@ export interface EmitScopeOptions {
33
35
  * so generated code never references undefined types.
34
36
  */
35
37
  errorKeys?: Set<string>
38
+ /**
39
+ * Maps a model `$id` to its shared model type name (built by the run module
40
+ * from ALL models — generated and imported). When present and non-empty,
41
+ * `$id` subschemas are rewritten to `x-named-type` nodes before ajsc; ajsc
42
+ * emits bare references and reports them via `referencedNamedTypes`, which
43
+ * drives the `import type { … } from './_models'` line. Absent/empty →
44
+ * identical inlining behaviour (the conversion wrappers short-circuit so
45
+ * output is byte-identical for envelopes without models).
46
+ */
47
+ idToModelName?: Map<string, string>
36
48
  }
37
49
 
38
50
  interface RouteChunks {
@@ -49,6 +61,14 @@ interface EmitRouteContext {
49
61
  scopePascal: string
50
62
  serviceName: string
51
63
  errorKeys?: Set<string>
64
+ /** `$id` → shared model type name; drives the substitute wrappers. */
65
+ idToModelName?: Map<string, string>
66
+ /**
67
+ * Accumulates the model names ajsc reported as `referencedNamedTypes` across
68
+ * every route in the scope. Drives the `import type { … } from './_models'`
69
+ * line assembled in `emitScopeFile`.
70
+ */
71
+ referencedModels: Set<string>
52
72
  }
53
73
 
54
74
  // ---------------------------------------------------------------------------
@@ -110,6 +130,79 @@ function indent(text: string, prefix: string): string {
110
130
  return text.split('\n').map((line) => (line ? prefix + line : line)).join('\n')
111
131
  }
112
132
 
133
+ // ---------------------------------------------------------------------------
134
+ // Shared-model conversion wrappers (ajsc x-named-type)
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Drop-in replacement for `jsonSchemaToExtractedTypes` that, when shared models
139
+ * are in play, rewrites every `$id` subschema into an `x-named-type` node before
140
+ * ajsc. ajsc emits each as a bare verbatim reference and reports it via
141
+ * `referencedNamedTypes`; this wrapper folds those names into
142
+ * `ctx.referencedModels` so `emitScopeFile` can build the `_models` import.
143
+ *
144
+ * Short-circuits to the exact original call when no models are configured, so
145
+ * output is byte-identical for envelopes without `$id` models.
146
+ */
147
+ async function convertExtracted(
148
+ schema: Record<string, unknown>,
149
+ ctx: EmitRouteContext,
150
+ ): Promise<ExtractedTypeOutput | undefined> {
151
+ if (ctx.idToModelName == null || ctx.idToModelName.size === 0) {
152
+ return jsonSchemaToExtractedTypes(schema, ctx.ajsc)
153
+ }
154
+ const { schema: sub } = substituteModelRefs(schema, ctx.idToModelName)
155
+ const result = await jsonSchemaToExtractedTypes(sub, ctx.ajsc)
156
+ if (result != null) {
157
+ assertNoModelNameCollision(result)
158
+ for (const name of result.referencedNamedTypes) ctx.referencedModels.add(name)
159
+ }
160
+ return result
161
+ }
162
+
163
+ /**
164
+ * Guards ajsc's documented `x-named-type` collision caveat. If a shared-model
165
+ * name (referenced via `x-named-type`) also matches the name ajsc derives for a
166
+ * sibling *structural* sub-type, ajsc silently MERGES them — the model reference
167
+ * resolves to the unrelated structural type. The merge happens inside ajsc's own
168
+ * pass, so it can't be disentangled afterward (a post-hoc rename would rewrite
169
+ * the model reference too, producing a silently-wrong type). Instead we detect
170
+ * the collision — a name present in BOTH `referencedNamedTypes` AND
171
+ * `extractedTypeNames` (ajsc's own documented collision signal) — and fail fast
172
+ * with an actionable message. The throw is wrapped with the route's name/scope
173
+ * by `emitScopeFile`.
174
+ */
175
+ function assertNoModelNameCollision(result: ExtractedTypeOutput): void {
176
+ if (result.referencedNamedTypes.length === 0 || result.extractedTypeNames.length === 0) return
177
+ const extracted = new Set(result.extractedTypeNames)
178
+ const collisions = result.referencedNamedTypes.filter((name) => extracted.has(name))
179
+ if (collisions.length === 0) return
180
+ const names = collisions.map((n) => `'${n}'`).join(', ')
181
+ throw new Error(
182
+ `shared model ${names} collides with a generated type of the same name derived ` +
183
+ `from a property in this route's schema — ajsc would silently merge them. Rename ` +
184
+ `the colliding property, or change the model's $id/title so the generated names differ`,
185
+ )
186
+ }
187
+
188
+ /**
189
+ * Drop-in replacement for `jsonSchemaToTypeBody` mirroring {@link convertExtracted}
190
+ * for the bare-body (flat / merged-alias) conversion path.
191
+ */
192
+ async function convertBody(
193
+ schema: Record<string, unknown>,
194
+ ctx: EmitRouteContext,
195
+ ): Promise<string | undefined> {
196
+ if (ctx.idToModelName == null || ctx.idToModelName.size === 0) {
197
+ return jsonSchemaToTypeBody(schema, ctx.ajsc)
198
+ }
199
+ const { schema: sub } = substituteModelRefs(schema, ctx.idToModelName)
200
+ const result = await jsonSchemaToTypeBodyWithRefs(sub, ctx.ajsc)
201
+ if (result == null) return undefined
202
+ for (const name of result.referencedNamedTypes) ctx.referencedModels.add(name)
203
+ return result.body
204
+ }
205
+
113
206
  /**
114
207
  * Tracks extracted declarations emitted into a single namespace, guarding
115
208
  * against duplicate identifiers (defense-in-depth on top of the rename pass).
@@ -200,7 +293,7 @@ async function formatTypes(
200
293
  for (const { shortName, schema } of types) {
201
294
  if (schema == null) continue
202
295
 
203
- const rawResult = await jsonSchemaToExtractedTypes(schema, ctx.ajsc)
296
+ const rawResult = await convertExtracted(schema, ctx)
204
297
  if (rawResult == null) continue
205
298
 
206
299
  const result = renameExtractedTypes(rawResult, taken)
@@ -223,7 +316,7 @@ async function formatTypes(
223
316
  if (schema == null) continue
224
317
 
225
318
  const flatName = `${routePascal}${shortName}`
226
- const body = await jsonSchemaToTypeBody(schema, ctx.ajsc)
319
+ const body = await convertBody(schema, ctx)
227
320
  if (body == null) continue
228
321
 
229
322
  declarations.push(`export type ${flatName} = ${body}`)
@@ -359,7 +452,7 @@ async function formatSubNamespace(
359
452
  if (schema == null) continue
360
453
 
361
454
  if (ctx.namespaceTypes) {
362
- const rawResult = await jsonSchemaToExtractedTypes(schema, ctx.ajsc)
455
+ const rawResult = await convertExtracted(schema, ctx)
363
456
  if (rawResult == null) continue
364
457
 
365
458
  const result = renameExtractedTypes(rawResult, taken)
@@ -373,7 +466,7 @@ async function formatSubNamespace(
373
466
  refs[shortName] = `${ctx.scopePascal}.${routePascal}.${nsName}.${shortName}`
374
467
  } else {
375
468
  const flatName = `${routePascal}${nsName}${shortName}`
376
- const body = await jsonSchemaToTypeBody(schema, ctx.ajsc)
469
+ const body = await convertBody(schema, ctx)
377
470
  if (body == null) continue
378
471
  refs[shortName] = flatName
379
472
  }
@@ -443,7 +536,8 @@ async function emitApiRoute(route: APIHttpRouteDoc, ctx: EmitRouteContext): Prom
443
536
  let paramsTypeName = 'unknown'
444
537
  let returnTypeName = 'void'
445
538
 
446
- // Track reserved names across all sub-namespaces
539
+ // Track reserved names across all sub-namespaces. Model names are reserved
540
+ // so an ajsc-extracted sub-type can't silently merge with a referenced model.
447
541
  const taken = new Set<string>(['Req', 'Response'])
448
542
 
449
543
  if (ctx.namespaceTypes) {
@@ -493,7 +587,7 @@ async function emitApiRoute(route: APIHttpRouteDoc, ctx: EmitRouteContext): Prom
493
587
  for (const { shortName, schema } of reqTypes) {
494
588
  if (schema == null) continue
495
589
  const flatName = `${pascal}Req${shortName}`
496
- const body = await jsonSchemaToTypeBody(schema, ctx.ajsc)
590
+ const body = await convertBody(schema, ctx)
497
591
  if (body == null) continue
498
592
  declarations.push(`export type ${flatName} = ${body}`)
499
593
  }
@@ -514,7 +608,7 @@ async function emitApiRoute(route: APIHttpRouteDoc, ctx: EmitRouteContext): Prom
514
608
  for (const { shortName, schema } of resTypes) {
515
609
  if (schema == null) continue
516
610
  const flatName = `${pascal}Response${shortName}`
517
- const body = await jsonSchemaToTypeBody(schema, ctx.ajsc)
611
+ const body = await convertBody(schema, ctx)
518
612
  if (body == null) continue
519
613
  declarations.push(`export type ${flatName} = ${body}`)
520
614
  if (shortName === 'Body') bodyRef = flatName
@@ -619,7 +713,7 @@ async function emitHttpStreamRoute(route: HttpStreamRouteDoc, ctx: EmitRouteCont
619
713
  const collector = new DeclarationCollector(`namespace ${pascal}`)
620
714
  for (const { shortName, schema } of directTypes) {
621
715
  if (schema == null) continue
622
- const rawResult = await jsonSchemaToExtractedTypes(schema, ctx.ajsc)
716
+ const rawResult = await convertExtracted(schema, ctx)
623
717
  if (rawResult == null) continue
624
718
  const result = renameExtractedTypes(rawResult, taken)
625
719
  for (const decl of result.declarations) {
@@ -644,7 +738,7 @@ async function emitHttpStreamRoute(route: HttpStreamRouteDoc, ctx: EmitRouteCont
644
738
  for (const { shortName, schema } of reqTypes) {
645
739
  if (schema == null) continue
646
740
  const flatName = `${pascal}Req${shortName}`
647
- const body = await jsonSchemaToTypeBody(schema, ctx.ajsc)
741
+ const body = await convertBody(schema, ctx)
648
742
  if (body == null) continue
649
743
  declarations.push(`export type ${flatName} = ${body}`)
650
744
  }
@@ -658,12 +752,12 @@ async function emitHttpStreamRoute(route: HttpStreamRouteDoc, ctx: EmitRouteCont
658
752
  }
659
753
 
660
754
  if (resHeadersSchema != null) {
661
- const body = await jsonSchemaToTypeBody(resHeadersSchema, ctx.ajsc)
755
+ const body = await convertBody(resHeadersSchema, ctx)
662
756
  if (body != null) declarations.push(`export type ${pascal}ResponseHeaders = ${body}`)
663
757
  }
664
758
 
665
759
  if (yieldSchema != null) {
666
- const body = await jsonSchemaToTypeBody(yieldSchema, ctx.ajsc)
760
+ const body = await convertBody(yieldSchema, ctx)
667
761
  if (body != null) {
668
762
  declarations.push(`export type ${pascal}Yield = ${body}`)
669
763
  yieldTypeName = `${pascal}Yield`
@@ -671,7 +765,7 @@ async function emitHttpStreamRoute(route: HttpStreamRouteDoc, ctx: EmitRouteCont
671
765
  }
672
766
 
673
767
  if (returnSchema != null) {
674
- const body = await jsonSchemaToTypeBody(returnSchema, ctx.ajsc)
768
+ const body = await convertBody(returnSchema, ctx)
675
769
  if (body != null) {
676
770
  declarations.push(`export type ${pascal}ReturnType = ${body}`)
677
771
  returnTypeName = `${pascal}ReturnType`
@@ -770,15 +864,19 @@ export async function emitScopeFile(
770
864
  namespaceTypes = false,
771
865
  serviceName = 'Api',
772
866
  errorKeys,
867
+ idToModelName,
773
868
  } = options ?? {}
774
869
 
775
870
  const pascal = toPascalCase(group.camelCase)
871
+ const referencedModels = new Set<string>()
776
872
  const ctx: EmitRouteContext = {
777
873
  ajsc: ajscOpts,
778
874
  namespaceTypes,
779
875
  scopePascal: pascal,
780
876
  serviceName,
781
877
  errorKeys,
878
+ idToModelName,
879
+ referencedModels,
782
880
  }
783
881
 
784
882
  const allTypeDeclarations: string[] = []
@@ -833,10 +931,13 @@ export async function emitScopeFile(
833
931
  }
834
932
  }
835
933
 
836
- const importsBlock = [clientImports, errorsImport].filter(Boolean).join('\n')
837
-
838
934
  const callablesBlock = callables.join('\n\n')
839
935
 
936
+ // Assemble the type + callable section for this scope. ajsc already emitted
937
+ // bare model references (via `x-named-type`); `convertExtracted` / `convertBody`
938
+ // folded the reported names into `ctx.referencedModels`, which drives the
939
+ // `_models` import below. No post-pass over the body is needed.
940
+ let body: string
840
941
  if (namespaceTypes) {
841
942
  // Namespace mode: types AND `bindScope` live inside `export namespace ${pascal}`.
842
943
  // Putting the function inside the namespace makes the merged symbol
@@ -857,36 +958,47 @@ export async function emitScopeFile(
857
958
  ].join('\n'),
858
959
  ].join('\n\n')
859
960
 
860
- return [
861
- CODEGEN_HEADER,
862
- importsBlock,
863
- '',
961
+ body = [
864
962
  `export namespace ${pascal} {`,
865
963
  namespaceMembers,
866
964
  '}',
867
965
  '',
868
966
  ].join('\n')
967
+ } else {
968
+ // Flat mode: types at module level, `bind${pascal}Scope` standalone.
969
+ const typesBlock = allTypeDeclarations.length > 0
970
+ ? allTypeDeclarations.join('\n') + '\n'
971
+ : ''
972
+
973
+ body = [
974
+ '// ── Types ────────────────────────────────────────',
975
+ '',
976
+ typesBlock,
977
+ '// ── Callables ────────────────────────────────────',
978
+ '',
979
+ `export function bind${pascal}Scope(client: ClientInstance) {`,
980
+ ' return {',
981
+ callablesBlock,
982
+ ' }',
983
+ '}',
984
+ '',
985
+ ].join('\n')
869
986
  }
870
987
 
871
- // Flat mode: types at module level, `bind${pascal}Scope` standalone.
872
- const typesBlock = allTypeDeclarations.length > 0
873
- ? allTypeDeclarations.join('\n') + '\n'
874
- : ''
988
+ // Build the shared-models import line when any route referenced a `$id` model.
989
+ // Matches the `_errors` import convention exactly: `'./_models'` with no `.js`.
990
+ let modelsImport = ''
991
+ if (referencedModels.size > 0) {
992
+ const names = [...referencedModels].sort().join(', ')
993
+ modelsImport = `import type { ${names} } from './_models'`
994
+ }
995
+
996
+ const importsBlock = [clientImports, errorsImport, modelsImport].filter(Boolean).join('\n')
875
997
 
876
998
  return [
877
999
  CODEGEN_HEADER,
878
1000
  importsBlock,
879
1001
  '',
880
- '// ── Types ────────────────────────────────────────',
881
- '',
882
- typesBlock,
883
- '// ── Callables ────────────────────────────────────',
884
- '',
885
- `export function bind${pascal}Scope(client: ClientInstance) {`,
886
- ' return {',
887
- callablesBlock,
888
- ' }',
889
- '}',
890
- '',
1002
+ body,
891
1003
  ].join('\n')
892
1004
  }