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.
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -8
- package/agent_config/claude-code/skills/ts-procedures/templates/client.md +3 -3
- package/agent_config/claude-code/skills/ts-procedures/templates/hono.md +3 -3
- package/agent_config/claude-code/skills/ts-procedures/templates/procedure.md +3 -3
- package/agent_config/claude-code/skills/ts-procedures/templates/stream-procedure.md +3 -3
- package/build/client/call.js +1 -1
- package/build/client/call.js.map +1 -1
- package/build/client/index.d.ts +1 -1
- package/build/client/index.js +23 -1
- package/build/client/index.js.map +1 -1
- package/build/client/index.test.js +87 -0
- package/build/client/index.test.js.map +1 -1
- package/build/client/resolve-options.d.ts +5 -4
- package/build/client/resolve-options.js +18 -7
- package/build/client/resolve-options.js.map +1 -1
- package/build/client/resolve-options.test.js +53 -24
- package/build/client/resolve-options.test.js.map +1 -1
- package/build/client/stream.js +1 -1
- package/build/client/stream.js.map +1 -1
- package/build/client/types.d.ts +31 -3
- package/build/codegen/__fixtures__/make-envelope.d.ts +41 -0
- package/build/codegen/__fixtures__/make-envelope.js +38 -0
- package/build/codegen/__fixtures__/make-envelope.js.map +1 -0
- package/build/codegen/bin/cli.d.ts +15 -0
- package/build/codegen/bin/cli.js +46 -21
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/bin/cli.test.js +54 -1
- package/build/codegen/bin/cli.test.js.map +1 -1
- package/build/codegen/bin/flag-specs.d.ts +10 -0
- package/build/codegen/bin/flag-specs.js +62 -0
- package/build/codegen/bin/flag-specs.js.map +1 -0
- package/build/codegen/bin/flag-specs.test.d.ts +1 -0
- package/build/codegen/bin/flag-specs.test.js +35 -0
- package/build/codegen/bin/flag-specs.test.js.map +1 -0
- package/build/codegen/collect-models.d.ts +48 -0
- package/build/codegen/collect-models.js +84 -0
- package/build/codegen/collect-models.js.map +1 -0
- package/build/codegen/collect-models.test.d.ts +1 -0
- package/build/codegen/collect-models.test.js +59 -0
- package/build/codegen/collect-models.test.js.map +1 -0
- package/build/codegen/emit-client-runtime.js +1 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/build/codegen/emit-models.d.ts +26 -0
- package/build/codegen/emit-models.js +53 -0
- package/build/codegen/emit-models.js.map +1 -0
- package/build/codegen/emit-models.test.d.ts +1 -0
- package/build/codegen/emit-models.test.js +42 -0
- package/build/codegen/emit-models.test.js.map +1 -0
- package/build/codegen/emit-scope.d.ts +10 -0
- package/build/codegen/emit-scope.js +119 -34
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-types.d.ts +26 -1
- package/build/codegen/emit-types.js +27 -5
- package/build/codegen/emit-types.js.map +1 -1
- package/build/codegen/index.d.ts +15 -0
- package/build/codegen/index.js +5 -0
- package/build/codegen/index.js.map +1 -1
- package/build/codegen/model-refs.d.ts +27 -0
- package/build/codegen/model-refs.js +49 -0
- package/build/codegen/model-refs.js.map +1 -0
- package/build/codegen/model-refs.test.d.ts +1 -0
- package/build/codegen/model-refs.test.js +33 -0
- package/build/codegen/model-refs.test.js.map +1 -0
- package/build/codegen/pipeline.d.ts +7 -0
- package/build/codegen/pipeline.js +6 -1
- package/build/codegen/pipeline.js.map +1 -1
- package/build/codegen/schema-walk.d.ts +13 -0
- package/build/codegen/schema-walk.js +26 -0
- package/build/codegen/schema-walk.js.map +1 -0
- package/build/codegen/schema-walk.test.d.ts +1 -0
- package/build/codegen/schema-walk.test.js +35 -0
- package/build/codegen/schema-walk.test.js.map +1 -0
- package/build/codegen/targets/_shared/target-run.d.ts +15 -0
- package/build/codegen/targets/ts/run.js +37 -1
- package/build/codegen/targets/ts/run.js.map +1 -1
- package/build/codegen/targets/ts/shared-models.test.d.ts +1 -0
- package/build/codegen/targets/ts/shared-models.test.js +354 -0
- package/build/codegen/targets/ts/shared-models.test.js.map +1 -0
- package/build/doc-envelope.d.ts +13 -0
- package/build/doc-envelope.js +23 -0
- package/build/doc-envelope.js.map +1 -0
- package/build/doc-envelope.test.d.ts +1 -0
- package/build/doc-envelope.test.js +31 -0
- package/build/doc-envelope.test.js.map +1 -0
- package/build/exports.d.ts +2 -0
- package/build/exports.js +1 -0
- package/build/exports.js.map +1 -1
- package/docs/client-and-codegen.md +163 -0
- package/docs/handoffs/ajsc-named-type-collision.md +134 -0
- package/docs/handoffs/ajsc-named-type-support.md +181 -0
- package/docs/handoffs/shared-models-auto-resolve-response.md +181 -0
- package/docs/superpowers/plans/2026-06-05-dx-feedback-round.md +1292 -0
- package/docs/superpowers/plans/2026-06-06-shared-models-convention-and-diagnostics.md +659 -0
- package/docs/superpowers/specs/2026-06-05-dx-feedback-round-design.md +285 -0
- package/package.json +2 -2
- package/src/client/call.ts +1 -1
- package/src/client/index.test.ts +98 -0
- package/src/client/index.ts +32 -1
- package/src/client/resolve-options.test.ts +73 -26
- package/src/client/resolve-options.ts +23 -9
- package/src/client/stream.ts +1 -1
- package/src/client/types.ts +34 -3
- package/src/codegen/__fixtures__/make-envelope.ts +89 -0
- package/src/codegen/bin/cli.test.ts +65 -1
- package/src/codegen/bin/cli.ts +51 -22
- package/src/codegen/bin/flag-specs.test.ts +38 -0
- package/src/codegen/bin/flag-specs.ts +71 -0
- package/src/codegen/collect-models.test.ts +68 -0
- package/src/codegen/collect-models.ts +125 -0
- package/src/codegen/emit-client-runtime.ts +1 -0
- package/src/codegen/emit-models.test.ts +48 -0
- package/src/codegen/emit-models.ts +63 -0
- package/src/codegen/emit-scope.ts +145 -33
- package/src/codegen/emit-types.ts +48 -7
- package/src/codegen/index.ts +20 -0
- package/src/codegen/model-refs.test.ts +37 -0
- package/src/codegen/model-refs.ts +57 -0
- package/src/codegen/pipeline.ts +13 -1
- package/src/codegen/schema-walk.test.ts +37 -0
- package/src/codegen/schema-walk.ts +23 -0
- package/src/codegen/targets/_shared/target-run.ts +15 -0
- package/src/codegen/targets/ts/run.ts +50 -0
- package/src/codegen/targets/ts/shared-models.test.ts +391 -0
- package/src/doc-envelope.test.ts +35 -0
- package/src/doc-envelope.ts +30 -0
- 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
|
+
}
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
872
|
-
|
|
873
|
-
|
|
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
|
-
|
|
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
|
}
|