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.
- 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 +11 -0
- package/build/codegen/bin/cli.js +30 -21
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/bin/cli.test.js +36 -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 +60 -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 +26 -0
- package/build/codegen/bin/flag-specs.test.js.map +1 -0
- package/build/codegen/collect-models.d.ts +37 -0
- package/build/codegen/collect-models.js +74 -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 +40 -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 +5 -0
- package/build/codegen/index.js +2 -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 +3 -0
- package/build/codegen/pipeline.js +3 -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 +5 -0
- package/build/codegen/targets/ts/run.js +28 -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 +258 -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 +101 -0
- package/docs/handoffs/ajsc-named-type-collision.md +134 -0
- package/docs/handoffs/ajsc-named-type-support.md +181 -0
- package/docs/superpowers/plans/2026-06-05-dx-feedback-round.md +1292 -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 +38 -1
- package/src/codegen/bin/cli.ts +33 -22
- package/src/codegen/bin/flag-specs.test.ts +27 -0
- package/src/codegen/bin/flag-specs.ts +69 -0
- package/src/codegen/collect-models.test.ts +46 -0
- package/src/codegen/collect-models.ts +108 -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 +7 -0
- package/src/codegen/model-refs.test.ts +37 -0
- package/src/codegen/model-refs.ts +57 -0
- package/src/codegen/pipeline.ts +6 -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 +5 -0
- package/src/codegen/targets/ts/run.ts +33 -0
- package/src/codegen/targets/ts/shared-models.test.ts +283 -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,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
|
}
|
|
@@ -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
|
-
|
|
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
|
|
64
|
-
return `export type ${typeName} = ${
|
|
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 {
|
|
332
|
+
return {
|
|
333
|
+
declarations,
|
|
334
|
+
body,
|
|
335
|
+
referencedNamedTypes: result.referencedNamedTypes ?? [],
|
|
336
|
+
extractedTypeNames: result.extractedTypeNames ?? [],
|
|
337
|
+
}
|
|
297
338
|
}
|
package/src/codegen/index.ts
CHANGED
|
@@ -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
|
+
}
|