ts-procedures 8.3.0 → 8.4.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 (124) 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 +11 -0
  25. package/build/codegen/bin/cli.js +30 -21
  26. package/build/codegen/bin/cli.js.map +1 -1
  27. package/build/codegen/bin/cli.test.js +36 -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 +60 -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 +26 -0
  34. package/build/codegen/bin/flag-specs.test.js.map +1 -0
  35. package/build/codegen/collect-models.d.ts +37 -0
  36. package/build/codegen/collect-models.js +74 -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 +40 -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 +5 -0
  56. package/build/codegen/index.js +2 -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 +3 -0
  65. package/build/codegen/pipeline.js +3 -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 +5 -0
  74. package/build/codegen/targets/ts/run.js +28 -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 +258 -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 +101 -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/superpowers/plans/2026-06-05-dx-feedback-round.md +1292 -0
  92. package/docs/superpowers/specs/2026-06-05-dx-feedback-round-design.md +285 -0
  93. package/package.json +2 -2
  94. package/src/client/call.ts +1 -1
  95. package/src/client/index.test.ts +98 -0
  96. package/src/client/index.ts +32 -1
  97. package/src/client/resolve-options.test.ts +73 -26
  98. package/src/client/resolve-options.ts +23 -9
  99. package/src/client/stream.ts +1 -1
  100. package/src/client/types.ts +34 -3
  101. package/src/codegen/__fixtures__/make-envelope.ts +89 -0
  102. package/src/codegen/bin/cli.test.ts +38 -1
  103. package/src/codegen/bin/cli.ts +33 -22
  104. package/src/codegen/bin/flag-specs.test.ts +27 -0
  105. package/src/codegen/bin/flag-specs.ts +69 -0
  106. package/src/codegen/collect-models.test.ts +46 -0
  107. package/src/codegen/collect-models.ts +108 -0
  108. package/src/codegen/emit-client-runtime.ts +1 -0
  109. package/src/codegen/emit-models.test.ts +48 -0
  110. package/src/codegen/emit-models.ts +63 -0
  111. package/src/codegen/emit-scope.ts +145 -33
  112. package/src/codegen/emit-types.ts +48 -7
  113. package/src/codegen/index.ts +7 -0
  114. package/src/codegen/model-refs.test.ts +37 -0
  115. package/src/codegen/model-refs.ts +57 -0
  116. package/src/codegen/pipeline.ts +6 -1
  117. package/src/codegen/schema-walk.test.ts +37 -0
  118. package/src/codegen/schema-walk.ts +23 -0
  119. package/src/codegen/targets/_shared/target-run.ts +5 -0
  120. package/src/codegen/targets/ts/run.ts +33 -0
  121. package/src/codegen/targets/ts/shared-models.test.ts +283 -0
  122. package/src/doc-envelope.test.ts +35 -0
  123. package/src/doc-envelope.ts +30 -0
  124. package/src/exports.ts +2 -0
@@ -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
  }
@@ -22,7 +22,7 @@ async function validateAjscFormat() {
22
22
  async function resolveTypeBody(
23
23
  schema: Record<string, unknown>,
24
24
  options?: AjscOptions,
25
- ): Promise<string> {
25
+ ): Promise<{ body: string; referencedNamedTypes: string[] }> {
26
26
  await validateAjscFormat()
27
27
 
28
28
  const { TypescriptConverter } = await import('ajsc')
@@ -43,7 +43,9 @@ async function resolveTypeBody(
43
43
  // Remove trailing semicolons and newlines
44
44
  code = code.replace(/;\s*$/, '').trim()
45
45
 
46
- return code
46
+ const referencedNamedTypes = (converter.referencedNamedTypes as string[] | undefined) ?? []
47
+
48
+ return { body: code, referencedNamedTypes }
47
49
  }
48
50
 
49
51
  /**
@@ -60,8 +62,8 @@ export async function jsonSchemaToTypeString(
60
62
  options?: AjscOptions,
61
63
  ): Promise<string | undefined> {
62
64
  if (schema == null) return undefined
63
- const code = await resolveTypeBody(schema, options)
64
- return `export type ${typeName} = ${code}`
65
+ const { body } = await resolveTypeBody(schema, options)
66
+ return `export type ${typeName} = ${body}`
65
67
  }
66
68
 
67
69
  /**
@@ -76,6 +78,23 @@ export async function jsonSchemaToTypeBody(
76
78
  schema: Record<string, unknown> | undefined,
77
79
  options?: AjscOptions,
78
80
  ): Promise<string | undefined> {
81
+ if (schema == null) return undefined
82
+ const { body } = await resolveTypeBody(schema, options)
83
+ return body
84
+ }
85
+
86
+ /**
87
+ * Like {@link jsonSchemaToTypeBody} but also surfaces the ajsc converter's
88
+ * `referencedNamedTypes` — the bare type names emitted verbatim because they
89
+ * carried an `x-named-type` keyword (see `model-refs.ts`). Drives the
90
+ * `_models` import in flat-mode scope emission.
91
+ *
92
+ * Returns undefined for undefined or null schema.
93
+ */
94
+ export async function jsonSchemaToTypeBodyWithRefs(
95
+ schema: Record<string, unknown> | undefined,
96
+ options?: AjscOptions,
97
+ ): Promise<{ body: string; referencedNamedTypes: string[] } | undefined> {
79
98
  if (schema == null) return undefined
80
99
  return resolveTypeBody(schema, options)
81
100
  }
@@ -89,6 +108,19 @@ export interface ExtractedTypeOutput {
89
108
  declarations: string[]
90
109
  /** The root type body (bare literal, e.g., `{ user: User; }`) without the `export type Root =` wrapper. */
91
110
  body: string
111
+ /**
112
+ * Bare type names ajsc emitted verbatim (because they carried an
113
+ * `x-named-type` keyword — see `model-refs.ts`). Drives the `_models`
114
+ * import in namespace-mode scope emission. Empty when no models referenced.
115
+ */
116
+ referencedNamedTypes: string[]
117
+ /**
118
+ * Names ajsc declared/extracted as named sub-types in this output (its own
119
+ * `extractedTypeNames`). Intersecting this with {@link referencedNamedTypes}
120
+ * is ajsc's documented signal that an `x-named-type` model collided with a
121
+ * structural sub-type of the same name (see `assertNoModelNameCollision`).
122
+ */
123
+ extractedTypeNames: string[]
92
124
  }
93
125
 
94
126
  /**
@@ -154,6 +186,9 @@ export async function jsonSchemaToExtractedTypes(
154
186
  inlineTypes: false,
155
187
  })
156
188
 
189
+ const referencedNamedTypes = (converter.referencedNamedTypes as string[] | undefined) ?? []
190
+ const extractedTypeNames = (converter.extractedTypeNames as string[] | undefined) ?? []
191
+
157
192
  const code = (converter.code as string).trim()
158
193
  if (!code) {
159
194
  throw new Error(
@@ -206,7 +241,7 @@ export async function jsonSchemaToExtractedTypes(
206
241
  .trim()
207
242
  }
208
243
 
209
- return { declarations, body }
244
+ return { declarations, body, referencedNamedTypes, extractedTypeNames }
210
245
  }
211
246
 
212
247
  /**
@@ -245,7 +280,8 @@ export function extractedDeclName(decl: string): string | undefined {
245
280
  * name (a latent wrong-type bug that only hides when the shapes happen to match).
246
281
  */
247
282
  export function renameExtractedTypes(
248
- result: ExtractedTypeOutput,
283
+ result: Omit<ExtractedTypeOutput, 'referencedNamedTypes' | 'extractedTypeNames'> &
284
+ Partial<Pick<ExtractedTypeOutput, 'referencedNamedTypes' | 'extractedTypeNames'>>,
249
285
  taken: Set<string>,
250
286
  ): ExtractedTypeOutput {
251
287
  // Work on a mutable copy so we can patch cross-references after renaming.
@@ -293,5 +329,10 @@ export function renameExtractedTypes(
293
329
  }
294
330
  }
295
331
 
296
- return { declarations, body }
332
+ return {
333
+ declarations,
334
+ body,
335
+ referencedNamedTypes: result.referencedNamedTypes ?? [],
336
+ extractedTypeNames: result.extractedTypeNames ?? [],
337
+ }
297
338
  }
@@ -1,6 +1,7 @@
1
1
  import { resolveEnvelope, type ResolveInput } from './resolve-envelope.js'
2
2
  import { runPipeline, type GeneratedFile } from './pipeline.js'
3
3
  import type { AjscOptions } from './emit-types.js'
4
+ import type { SharedTypesImportMap } from './collect-models.js'
4
5
  import type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
5
6
  import type { SwiftEmitter } from './targets/swift/ajsc-adapter.js'
6
7
 
@@ -13,6 +14,10 @@ export interface GenerateClientOptions extends ResolveInput {
13
14
  selfContained?: boolean
14
15
  serviceName?: string
15
16
  cleanOutDir?: boolean
17
+ /** Hoist `$id`-bearing schemas into a shared `_models.ts` and reference them from scopes (TS target). */
18
+ shareModels?: boolean
19
+ /** Maps a model `$id` to an external import so a shared model is re-exported rather than generated. */
20
+ sharedTypesImport?: SharedTypesImportMap
16
21
  target?: 'ts' | 'kotlin' | 'swift'
17
22
  kotlinPackage?: string
18
23
  kotlinSerializer?: 'kotlinx' | 'none'
@@ -37,6 +42,8 @@ export async function generateClient(options: GenerateClientOptions): Promise<Ge
37
42
  selfContained: options.selfContained,
38
43
  serviceName: options.serviceName,
39
44
  cleanOutDir: options.cleanOutDir,
45
+ shareModels: options.shareModels,
46
+ sharedTypesImport: options.sharedTypesImport,
40
47
  target: options.target,
41
48
  kotlinPackage: options.kotlinPackage,
42
49
  kotlinSerializer: options.kotlinSerializer,
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { substituteModelRefs } from './model-refs.js'
3
+
4
+ const message = { type: 'object', $id: 'urn:msg', title: 'Message', properties: { id: { type: 'string' } } }
5
+
6
+ describe('substituteModelRefs', () => {
7
+ it('replaces an $id subschema with an x-named-type node keyed by model name', () => {
8
+ const schema = { type: 'object', properties: { author: message, tags: { type: 'array', items: message } } }
9
+ const idToName = new Map([['urn:msg', 'Message']])
10
+ const { schema: out, referenced } = substituteModelRefs(schema, idToName)
11
+ expect((out as any).properties.author).toEqual({ 'x-named-type': 'Message' })
12
+ expect((out as any).properties.tags.items).toEqual({ 'x-named-type': 'Message' })
13
+ expect([...referenced]).toEqual(['Message'])
14
+ })
15
+
16
+ it('does not mutate the input schema', () => {
17
+ const schema = { type: 'object', properties: { author: message } }
18
+ const before = JSON.stringify(schema)
19
+ substituteModelRefs(schema, new Map([['urn:msg', 'Message']]))
20
+ expect(JSON.stringify(schema)).toBe(before)
21
+ })
22
+
23
+ it('leaves the schema untouched when no $id is in the registry', () => {
24
+ const schema = { type: 'object', properties: { author: message } }
25
+ const { schema: out, referenced } = substituteModelRefs(schema, new Map())
26
+ expect(out).toEqual(schema)
27
+ expect(referenced.size).toBe(0)
28
+ })
29
+
30
+ it('does NOT replace the node when its own $id is the registry root being emitted (optional skipSelfId)', () => {
31
+ // When emitting the Message model itself, we don't want Message to become a ref to itself.
32
+ const idToName = new Map([['urn:msg', 'Message']])
33
+ const { schema: out } = substituteModelRefs(message, idToName, 'urn:msg')
34
+ expect(out).not.toHaveProperty('x-named-type')
35
+ expect(out.$id).toBe('urn:msg')
36
+ })
37
+ })
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared-`$id` model referencing via ajsc's `x-named-type` keyword (ajsc ≥7.3.0).
3
+ *
4
+ * ajsc inlines plain `$ref`s, so to make a route reference a hoisted model by
5
+ * name instead of re-inlining its shape, {@link substituteModelRefs} rewrites
6
+ * each `$id` subschema (BEFORE ajsc runs) into `{ 'x-named-type': '<Name>' }`.
7
+ * ajsc then emits a bare verbatim reference to `<Name>` (surviving arrays and
8
+ * both `inlineTypes` modes, never extracted as a sub-type) and reports it via
9
+ * the converter's `referencedNamedTypes` — which the emitter uses to add the
10
+ * `import type { … } from './_models'` line.
11
+ */
12
+
13
+ /**
14
+ * Returns a deep-cloned schema where every object node carrying a `$id` present
15
+ * in `idToName` is replaced by `{ 'x-named-type': '<Name>' }`. The input is
16
+ * never mutated.
17
+ *
18
+ * - Replaced nodes are NOT recursed into (they're gone).
19
+ * - A node whose `$id === skipSelfId` is left in place (used when emitting that
20
+ * model itself), but its CHILDREN are still recursed so nested OTHER models are
21
+ * still referenced.
22
+ *
23
+ * @returns the rewritten schema and the set of referenced model names.
24
+ */
25
+ export function substituteModelRefs(
26
+ schema: Record<string, unknown>,
27
+ idToName: Map<string, string>,
28
+ skipSelfId?: string
29
+ ): { schema: Record<string, unknown>; referenced: Set<string> } {
30
+ const referenced = new Set<string>()
31
+
32
+ const walk = (node: unknown): unknown => {
33
+ if (Array.isArray(node)) {
34
+ return node.map((item) => walk(item))
35
+ }
36
+ if (!node || typeof node !== 'object') return node
37
+
38
+ const obj = node as Record<string, unknown>
39
+ const id = typeof obj.$id === 'string' ? obj.$id : undefined
40
+
41
+ if (id !== undefined && id !== skipSelfId && idToName.has(id)) {
42
+ const name = idToName.get(id)!
43
+ referenced.add(name)
44
+ // Replace entirely — do not recurse into a node that's being swapped out.
45
+ return { 'x-named-type': name }
46
+ }
47
+
48
+ // Either no $id, or the self node we're emitting: deep-clone children and recurse.
49
+ const out: Record<string, unknown> = {}
50
+ for (const [k, v] of Object.entries(obj)) {
51
+ out[k] = walk(v)
52
+ }
53
+ return out
54
+ }
55
+
56
+ return { schema: walk(structuredClone(schema)) as Record<string, unknown>, referenced }
57
+ }