ts-procedures 8.4.0 → 8.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/codegen/bin/cli.d.ts +4 -0
- package/build/codegen/bin/cli.js +16 -0
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/bin/cli.test.js +18 -0
- package/build/codegen/bin/cli.test.js.map +1 -1
- package/build/codegen/bin/flag-specs.js +2 -0
- package/build/codegen/bin/flag-specs.js.map +1 -1
- package/build/codegen/bin/flag-specs.test.js +9 -0
- package/build/codegen/bin/flag-specs.test.js.map +1 -1
- package/build/codegen/collect-models.d.ts +14 -3
- package/build/codegen/collect-models.js +15 -5
- package/build/codegen/collect-models.js.map +1 -1
- package/build/codegen/collect-models.test.js +21 -2
- package/build/codegen/collect-models.test.js.map +1 -1
- package/build/codegen/emit-index.js +13 -0
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-index.test.js +25 -0
- package/build/codegen/emit-index.test.js.map +1 -1
- package/build/codegen/emit-scope.js +45 -8
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +86 -4
- package/build/codegen/emit-scope.test.js.map +1 -1
- package/build/codegen/index.d.ts +10 -0
- package/build/codegen/index.js +3 -0
- package/build/codegen/index.js.map +1 -1
- package/build/codegen/pipeline.d.ts +4 -0
- package/build/codegen/pipeline.js +3 -0
- package/build/codegen/pipeline.js.map +1 -1
- package/build/codegen/targets/_shared/target-run.d.ts +10 -0
- package/build/codegen/targets/ts/run.js +11 -2
- package/build/codegen/targets/ts/run.js.map +1 -1
- package/build/codegen/targets/ts/shared-models.test.js +97 -1
- package/build/codegen/targets/ts/shared-models.test.js.map +1 -1
- package/docs/client-and-codegen.md +62 -0
- package/docs/client-error-handling.md +87 -0
- package/docs/handoffs/2026-06-08-dx-round2-declines.md +45 -0
- package/docs/handoffs/shared-models-auto-resolve-response.md +181 -0
- package/docs/http-integrations.md +25 -0
- package/docs/superpowers/plans/2026-06-06-shared-models-convention-and-diagnostics.md +659 -0
- package/docs/superpowers/plans/2026-06-08-codegen-dx-surfacing.md +428 -0
- package/docs/superpowers/specs/2026-06-08-dx-feedback-round-2-design.md +376 -0
- package/package.json +1 -1
- package/src/codegen/__fixtures__/users-envelope.json +9 -0
- package/src/codegen/bin/cli.test.ts +27 -0
- package/src/codegen/bin/cli.ts +18 -0
- package/src/codegen/bin/flag-specs.test.ts +11 -0
- package/src/codegen/bin/flag-specs.ts +2 -0
- package/src/codegen/collect-models.test.ts +24 -2
- package/src/codegen/collect-models.ts +22 -5
- package/src/codegen/emit-index.test.ts +34 -0
- package/src/codegen/emit-index.ts +19 -0
- package/src/codegen/emit-scope.test.ts +94 -4
- package/src/codegen/emit-scope.ts +53 -8
- package/src/codegen/index.ts +13 -0
- package/src/codegen/pipeline.ts +7 -0
- package/src/codegen/targets/_shared/target-run.ts +10 -0
- package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +6 -0
- package/src/codegen/targets/swift/__fixtures__/users-golden.swift +6 -0
- package/src/codegen/targets/ts/run.ts +18 -1
- package/src/codegen/targets/ts/shared-models.test.ts +109 -1
|
@@ -106,6 +106,25 @@ export function emitIndexFile(groups: ScopeGroup[], options?: EmitIndexOptions):
|
|
|
106
106
|
'',
|
|
107
107
|
]
|
|
108
108
|
|
|
109
|
+
// Per-scope client interfaces, derived from the bindings factory's return type
|
|
110
|
+
// (DX #15). Reuses the `ReturnType<typeof factory>` pattern already used by the
|
|
111
|
+
// convenience client below. Lets a consumer type a dependency as the narrow scope
|
|
112
|
+
// port — `constructor(client: UsersClient = api.users)` — and a fake satisfies just
|
|
113
|
+
// that scope with no `as unknown as typeof api` cast. `${Service}Client` is the
|
|
114
|
+
// callable bundle; it does NOT collide with the `${Service}.<Scope>` type namespace.
|
|
115
|
+
// Each type carries a JSDoc line so the DI-seam intent is visible to a developer
|
|
116
|
+
// reading the generated file — not just in this emitter's source comment above.
|
|
117
|
+
const aggregateClientType = `${servicePascal}Client`
|
|
118
|
+
pieces.push(
|
|
119
|
+
`/** Full typed client surface — every scope of \`${servicePascal}\`. */`,
|
|
120
|
+
`export type ${aggregateClientType} = ReturnType<typeof ${factoryName}>`,
|
|
121
|
+
...groups.flatMap((g) => [
|
|
122
|
+
`/** Narrow port for the \`${g.camelCase}\` scope — inject as a DI seam without casting the aggregate client. */`,
|
|
123
|
+
`export type ${toPascalCase(g.camelCase)}Client = ${aggregateClientType}['${g.camelCase}']`,
|
|
124
|
+
]),
|
|
125
|
+
'',
|
|
126
|
+
)
|
|
127
|
+
|
|
109
128
|
// `createApiClient` is a convenience wrapper that wires `createClient` with
|
|
110
129
|
// the generated error registry baked in, then invokes the bindings factory.
|
|
111
130
|
// Consumers that want manual control over `createClient` still use the
|
|
@@ -1250,9 +1250,10 @@ describe('emitScopeFile API conditional return type', () => {
|
|
|
1250
1250
|
expect(out).toContain('{ headers: GetPreflightResponseHeaders }')
|
|
1251
1251
|
})
|
|
1252
1252
|
|
|
1253
|
-
it('neither body nor headers: return type is void', async () => {
|
|
1253
|
+
it('neither body nor headers: param type is void, return type is void', async () => {
|
|
1254
1254
|
const out = await emitScopeFile(apiGroupNoBody)
|
|
1255
|
-
expect(out).toContain('client.bindCallable<
|
|
1255
|
+
expect(out).toContain('client.bindCallable<void, void>')
|
|
1256
|
+
expect(out).not.toContain('client.bindCallable<unknown,')
|
|
1256
1257
|
})
|
|
1257
1258
|
})
|
|
1258
1259
|
|
|
@@ -1276,9 +1277,9 @@ describe('emitScopeFile API conditional return type', () => {
|
|
|
1276
1277
|
expect(out).toContain('{ headers: Preflight.GetPreflight.Response.Headers }')
|
|
1277
1278
|
})
|
|
1278
1279
|
|
|
1279
|
-
it('neither body nor headers: return type is void', async () => {
|
|
1280
|
+
it('neither body nor headers: param type is void, return type is void', async () => {
|
|
1280
1281
|
const out = await emitScopeFile(apiGroupNoBody, { namespaceTypes: true })
|
|
1281
|
-
expect(out).toContain('client.bindCallable<
|
|
1282
|
+
expect(out).toContain('client.bindCallable<void, void>')
|
|
1282
1283
|
})
|
|
1283
1284
|
})
|
|
1284
1285
|
})
|
|
@@ -1586,3 +1587,92 @@ describe('emitScopeFile http-stream kind', () => {
|
|
|
1586
1587
|
})
|
|
1587
1588
|
})
|
|
1588
1589
|
})
|
|
1590
|
+
|
|
1591
|
+
// Input-less RPC (no body) and stream (no params) — exercise the void-param fallback
|
|
1592
|
+
// across the RPC and stream emission paths, not just API.
|
|
1593
|
+
const rpcGroupNoInput: ScopeGroup = {
|
|
1594
|
+
scopeKey: 'session',
|
|
1595
|
+
camelCase: 'session',
|
|
1596
|
+
routes: [
|
|
1597
|
+
{
|
|
1598
|
+
kind: 'rpc',
|
|
1599
|
+
name: 'Logout',
|
|
1600
|
+
path: '/session/logout',
|
|
1601
|
+
method: 'post',
|
|
1602
|
+
scope: 'session',
|
|
1603
|
+
version: 1,
|
|
1604
|
+
jsonSchema: {
|
|
1605
|
+
response: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] },
|
|
1606
|
+
},
|
|
1607
|
+
} satisfies RPCHttpRouteDoc,
|
|
1608
|
+
],
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
const streamGroupNoParams: ScopeGroup = {
|
|
1612
|
+
scopeKey: 'ticks',
|
|
1613
|
+
camelCase: 'ticks',
|
|
1614
|
+
routes: [
|
|
1615
|
+
{
|
|
1616
|
+
kind: 'stream',
|
|
1617
|
+
name: 'WatchTicks',
|
|
1618
|
+
path: '/ticks/stream',
|
|
1619
|
+
methods: ['get'],
|
|
1620
|
+
streamMode: 'sse',
|
|
1621
|
+
scope: 'ticks',
|
|
1622
|
+
version: 1,
|
|
1623
|
+
jsonSchema: {
|
|
1624
|
+
params: undefined,
|
|
1625
|
+
yieldType: { type: 'object', properties: { n: { type: 'number' } }, required: ['n'] },
|
|
1626
|
+
returnType: undefined,
|
|
1627
|
+
},
|
|
1628
|
+
} satisfies StreamHttpRouteDoc,
|
|
1629
|
+
],
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
describe('emitScopeFile input-less routes (void params)', () => {
|
|
1633
|
+
it('RPC route with no body emits void as the params type arg', async () => {
|
|
1634
|
+
const out = await emitScopeFile(rpcGroupNoInput)
|
|
1635
|
+
expect(out).toContain('client.bindCallable<void,')
|
|
1636
|
+
expect(out).not.toContain('client.bindCallable<unknown,')
|
|
1637
|
+
})
|
|
1638
|
+
|
|
1639
|
+
it('stream route with no params emits void as the params type', async () => {
|
|
1640
|
+
const out = await emitScopeFile(streamGroupNoParams)
|
|
1641
|
+
// Stream callables are direct methods: WatchTicks(params: void, options?)
|
|
1642
|
+
expect(out).toContain('WatchTicks(params: void')
|
|
1643
|
+
expect(out).not.toContain('WatchTicks(params: unknown')
|
|
1644
|
+
})
|
|
1645
|
+
})
|
|
1646
|
+
|
|
1647
|
+
describe('emitScopeFile callable JSDoc surfaces options + errors', () => {
|
|
1648
|
+
it('mentions the per-call options bag on an RPC callable', async () => {
|
|
1649
|
+
const out = await emitScopeFile(rpcGroup)
|
|
1650
|
+
expect(out).toContain('POST') // existing method label preserved
|
|
1651
|
+
expect(out).toContain('@param options')
|
|
1652
|
+
expect(out).toContain('ProcedureCallOptions')
|
|
1653
|
+
expect(out).toContain('signal')
|
|
1654
|
+
})
|
|
1655
|
+
|
|
1656
|
+
it('mentions declared errors + Scope.Route.Errors on a route with errors (namespace)', async () => {
|
|
1657
|
+
const out = await emitScopeFile(rpcGroupWithErrors, {
|
|
1658
|
+
namespaceTypes: true,
|
|
1659
|
+
errorKeys: new Set(['NotFound']),
|
|
1660
|
+
serviceName: 'Api',
|
|
1661
|
+
})
|
|
1662
|
+
expect(out).toContain('@throws')
|
|
1663
|
+
expect(out).toContain('Users.GetUser.Errors')
|
|
1664
|
+
expect(out).toContain('instanceof')
|
|
1665
|
+
})
|
|
1666
|
+
|
|
1667
|
+
it('omits the @throws line when a route declares no errors', async () => {
|
|
1668
|
+
const out = await emitScopeFile(rpcGroupNoErrors, { serviceName: 'Api' })
|
|
1669
|
+
expect(out).not.toContain('@throws')
|
|
1670
|
+
expect(out).toContain('@param options') // options line still present
|
|
1671
|
+
})
|
|
1672
|
+
|
|
1673
|
+
it('mentions options on a stream callable but no @throws', async () => {
|
|
1674
|
+
const out = await emitScopeFile(streamGroup)
|
|
1675
|
+
expect(out).toContain('@param options')
|
|
1676
|
+
expect(out).not.toContain('@throws')
|
|
1677
|
+
})
|
|
1678
|
+
})
|
|
@@ -387,6 +387,35 @@ function injectRouteErrors(
|
|
|
387
387
|
// Route emitters
|
|
388
388
|
// ---------------------------------------------------------------------------
|
|
389
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Builds the multi-line JSDoc comment for a route callable. Surfaces the
|
|
392
|
+
* second `options` argument (the per-call AbortSignal/timeout seam — DX #8) and,
|
|
393
|
+
* for routes that declare typed errors, points at the route's `Errors` type and
|
|
394
|
+
* how to narrow it on the throwing path (DX #10). Indented for placement inside
|
|
395
|
+
* the bind-object (4 spaces).
|
|
396
|
+
*/
|
|
397
|
+
function buildCallableJsDoc(opts: {
|
|
398
|
+
methodLabel: string
|
|
399
|
+
path: string
|
|
400
|
+
errorsRef: string | null
|
|
401
|
+
}): string {
|
|
402
|
+
const lines = [
|
|
403
|
+
` /**`,
|
|
404
|
+
` * ${opts.methodLabel} ${opts.path}`,
|
|
405
|
+
` *`,
|
|
406
|
+
` * @param options Optional per-call {@link ProcedureCallOptions} —`,
|
|
407
|
+
` * \`signal\` (cancel on dispose), \`timeout\`, \`headers\`, \`basePath\`.`,
|
|
408
|
+
]
|
|
409
|
+
if (opts.errorsRef) {
|
|
410
|
+
lines.push(
|
|
411
|
+
` * @throws Declared typed errors: {@link ${opts.errorsRef}}. Narrow with`,
|
|
412
|
+
` * \`instanceof\` on the throwing path, or call \`.safe()\` for a \`Result\`.`,
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
lines.push(` */`)
|
|
416
|
+
return lines.join('\n')
|
|
417
|
+
}
|
|
418
|
+
|
|
390
419
|
async function emitRpcRoute(route: RPCHttpRouteDoc, ctx: EmitRouteContext): Promise<RouteChunks> {
|
|
391
420
|
const pascal = versionedPascal(route.name, route.version)
|
|
392
421
|
|
|
@@ -395,7 +424,7 @@ async function emitRpcRoute(route: RPCHttpRouteDoc, ctx: EmitRouteContext): Prom
|
|
|
395
424
|
{ shortName: 'Response', schema: route.jsonSchema.response },
|
|
396
425
|
], ctx)
|
|
397
426
|
|
|
398
|
-
const paramsTypeName = refs['Params'] ?? '
|
|
427
|
+
const paramsTypeName = refs['Params'] ?? 'void'
|
|
399
428
|
const responseTypeName = refs['Response'] ?? 'unknown'
|
|
400
429
|
const scopeStr = Array.isArray(route.scope) ? route.scope.join('-') : route.scope
|
|
401
430
|
|
|
@@ -409,7 +438,11 @@ async function emitRpcRoute(route: RPCHttpRouteDoc, ctx: EmitRouteContext): Prom
|
|
|
409
438
|
: `client.bindCallable<${paramsTypeName}, ${responseTypeName}>`
|
|
410
439
|
|
|
411
440
|
const callable = [
|
|
412
|
-
|
|
441
|
+
buildCallableJsDoc({
|
|
442
|
+
methodLabel: route.method.toUpperCase(),
|
|
443
|
+
path: route.path,
|
|
444
|
+
errorsRef: hasErrors ? errorsRef : null,
|
|
445
|
+
}),
|
|
413
446
|
` ${pascal}: ${helperCall}({`,
|
|
414
447
|
` name: '${pascal}',`,
|
|
415
448
|
` scope: '${scopeStr}',`,
|
|
@@ -533,7 +566,7 @@ async function emitApiRoute(route: APIHttpRouteDoc, ctx: EmitRouteContext): Prom
|
|
|
533
566
|
: `${pascal}Errors`
|
|
534
567
|
|
|
535
568
|
const declarations: string[] = []
|
|
536
|
-
let paramsTypeName = '
|
|
569
|
+
let paramsTypeName = 'void'
|
|
537
570
|
let returnTypeName = 'void'
|
|
538
571
|
|
|
539
572
|
// Track reserved names across all sub-namespaces. Model names are reserved
|
|
@@ -638,7 +671,11 @@ async function emitApiRoute(route: APIHttpRouteDoc, ctx: EmitRouteContext): Prom
|
|
|
638
671
|
]
|
|
639
672
|
|
|
640
673
|
const callable = [
|
|
641
|
-
|
|
674
|
+
buildCallableJsDoc({
|
|
675
|
+
methodLabel: route.method.toUpperCase(),
|
|
676
|
+
path: route.fullPath,
|
|
677
|
+
errorsRef: hasErrors ? errorsRef : null,
|
|
678
|
+
}),
|
|
642
679
|
` ${route.name}: ${helperCall}({`,
|
|
643
680
|
...descriptorLines,
|
|
644
681
|
` }),`,
|
|
@@ -676,7 +713,7 @@ async function emitHttpStreamRoute(route: HttpStreamRouteDoc, ctx: EmitRouteCont
|
|
|
676
713
|
|
|
677
714
|
const scopeStr = route.scope ?? 'default'
|
|
678
715
|
const declarations: string[] = []
|
|
679
|
-
let paramsTypeName = '
|
|
716
|
+
let paramsTypeName = 'void'
|
|
680
717
|
let yieldTypeName = 'unknown'
|
|
681
718
|
let returnTypeName = 'void'
|
|
682
719
|
|
|
@@ -774,7 +811,11 @@ async function emitHttpStreamRoute(route: HttpStreamRouteDoc, ctx: EmitRouteCont
|
|
|
774
811
|
}
|
|
775
812
|
|
|
776
813
|
const callable = [
|
|
777
|
-
|
|
814
|
+
buildCallableJsDoc({
|
|
815
|
+
methodLabel: route.method.toUpperCase(),
|
|
816
|
+
path: route.fullPath,
|
|
817
|
+
errorsRef: null,
|
|
818
|
+
}),
|
|
778
819
|
` ${route.name}(req: ${paramsTypeName}, options?: ProcedureCallOptions): TypedStream<${yieldTypeName}, ${returnTypeName}> {`,
|
|
779
820
|
` return client.stream<${yieldTypeName}, ${returnTypeName}>({`,
|
|
780
821
|
` name: '${route.name}',`,
|
|
@@ -813,13 +854,17 @@ async function emitStreamRoute(route: StreamHttpRouteDoc, ctx: EmitRouteContext)
|
|
|
813
854
|
{ shortName: 'Return', schema: route.jsonSchema.returnType },
|
|
814
855
|
], ctx)
|
|
815
856
|
|
|
816
|
-
const paramsTypeName = refs['Params'] ?? '
|
|
857
|
+
const paramsTypeName = refs['Params'] ?? 'void'
|
|
817
858
|
const yieldTypeName = refs['Yield'] ?? 'unknown'
|
|
818
859
|
const returnTypeName = refs['Return'] ?? 'void'
|
|
819
860
|
const scopeStr = Array.isArray(route.scope) ? route.scope.join('-') : route.scope
|
|
820
861
|
|
|
821
862
|
const callable = [
|
|
822
|
-
|
|
863
|
+
buildCallableJsDoc({
|
|
864
|
+
methodLabel: route.methods.map((m) => m.toUpperCase()).join('|'),
|
|
865
|
+
path: route.path,
|
|
866
|
+
errorsRef: null,
|
|
867
|
+
}),
|
|
823
868
|
` ${pascal}(params: ${paramsTypeName}, options?: ProcedureCallOptions): TypedStream<${yieldTypeName}, ${returnTypeName}> {`,
|
|
824
869
|
` return client.stream<${yieldTypeName}, ${returnTypeName}>({`,
|
|
825
870
|
` name: '${pascal}',`,
|
package/src/codegen/index.ts
CHANGED
|
@@ -18,6 +18,16 @@ export interface GenerateClientOptions extends ResolveInput {
|
|
|
18
18
|
shareModels?: boolean
|
|
19
19
|
/** Maps a model `$id` to an external import so a shared model is re-exported rather than generated. */
|
|
20
20
|
sharedTypesImport?: SharedTypesImportMap
|
|
21
|
+
/** Single module every $id-bearing model re-exports from (convention; `sharedTypesImport` entries override). */
|
|
22
|
+
sharedModelsModule?: string
|
|
23
|
+
/** Hard-fail codegen if any $id-bearing model would be generated as a local structural twin. */
|
|
24
|
+
strictSharedModels?: boolean
|
|
25
|
+
/**
|
|
26
|
+
* Sink for non-error progress messages (e.g. the shared-models summary).
|
|
27
|
+
* Omitted by default so programmatic callers produce no console output; pass
|
|
28
|
+
* `console.log` to opt in. The CLI wires this to stdout.
|
|
29
|
+
*/
|
|
30
|
+
logger?: (message: string) => void
|
|
21
31
|
target?: 'ts' | 'kotlin' | 'swift'
|
|
22
32
|
kotlinPackage?: string
|
|
23
33
|
kotlinSerializer?: 'kotlinx' | 'none'
|
|
@@ -44,6 +54,9 @@ export async function generateClient(options: GenerateClientOptions): Promise<Ge
|
|
|
44
54
|
cleanOutDir: options.cleanOutDir,
|
|
45
55
|
shareModels: options.shareModels,
|
|
46
56
|
sharedTypesImport: options.sharedTypesImport,
|
|
57
|
+
sharedModelsModule: options.sharedModelsModule,
|
|
58
|
+
strictSharedModels: options.strictSharedModels,
|
|
59
|
+
logger: options.logger,
|
|
47
60
|
target: options.target,
|
|
48
61
|
kotlinPackage: options.kotlinPackage,
|
|
49
62
|
kotlinSerializer: options.kotlinSerializer,
|
package/src/codegen/pipeline.ts
CHANGED
|
@@ -23,6 +23,10 @@ export interface PipelineOptions {
|
|
|
23
23
|
cleanOutDir?: boolean
|
|
24
24
|
shareModels?: boolean
|
|
25
25
|
sharedTypesImport?: SharedTypesImportMap
|
|
26
|
+
sharedModelsModule?: string
|
|
27
|
+
strictSharedModels?: boolean
|
|
28
|
+
/** Sink for non-error progress messages (shared-models summary). Defaults to silent. */
|
|
29
|
+
logger?: (message: string) => void
|
|
26
30
|
target?: 'ts' | 'kotlin' | 'swift'
|
|
27
31
|
kotlinPackage?: string
|
|
28
32
|
kotlinSerializer?: 'kotlinx' | 'none'
|
|
@@ -72,6 +76,9 @@ export async function runPipeline(options: PipelineOptions): Promise<GeneratedFi
|
|
|
72
76
|
cleanOutDir,
|
|
73
77
|
shareModels,
|
|
74
78
|
sharedTypesImport: options.sharedTypesImport,
|
|
79
|
+
sharedModelsModule: options.sharedModelsModule,
|
|
80
|
+
strictSharedModels: options.strictSharedModels,
|
|
81
|
+
logger: options.logger,
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
switch (options.target) {
|
|
@@ -30,4 +30,14 @@ export interface TargetRunInput {
|
|
|
30
30
|
shareModels?: boolean
|
|
31
31
|
/** Maps a model `$id` to an external import (re-exported instead of generated). */
|
|
32
32
|
sharedTypesImport?: SharedTypesImportMap
|
|
33
|
+
/** Single module every $id-bearing model re-exports from (convention; map entries override). */
|
|
34
|
+
sharedModelsModule?: string
|
|
35
|
+
/** When true, hard-fail if any $id-bearing model has no shared-type mapping (would be a local twin). */
|
|
36
|
+
strictSharedModels?: boolean
|
|
37
|
+
/**
|
|
38
|
+
* Sink for non-error progress messages (e.g. the shared-models summary).
|
|
39
|
+
* Defaults to undefined — programmatic callers stay silent; the CLI injects
|
|
40
|
+
* `console.log`. Output belongs to the caller, not the run module.
|
|
41
|
+
*/
|
|
42
|
+
logger?: (message: string) => void
|
|
33
43
|
}
|
|
@@ -33,6 +33,9 @@ export async function runTsPipeline(input: TsRunInput): Promise<GeneratedFile[]>
|
|
|
33
33
|
selfContained = false,
|
|
34
34
|
cleanOutDir = false,
|
|
35
35
|
sharedTypesImport,
|
|
36
|
+
sharedModelsModule,
|
|
37
|
+
strictSharedModels = false,
|
|
38
|
+
logger,
|
|
36
39
|
} = input
|
|
37
40
|
const shareModels = input.shareModels ?? false
|
|
38
41
|
|
|
@@ -60,7 +63,7 @@ export async function runTsPipeline(input: TsRunInput): Promise<GeneratedFile[]>
|
|
|
60
63
|
// covers ALL models (generated AND externally-imported) since scopes always
|
|
61
64
|
// import shared types from `./_models`.
|
|
62
65
|
const models = shareModels
|
|
63
|
-
? resolveModelImports(collectModels(envelope.routes), sharedTypesImport)
|
|
66
|
+
? resolveModelImports(collectModels(envelope.routes), { sharedTypesImport, sharedModelsModule })
|
|
64
67
|
: []
|
|
65
68
|
const idToModelName = new Map(models.map((m) => [m.id, m.name]))
|
|
66
69
|
|
|
@@ -74,6 +77,20 @@ export async function runTsPipeline(input: TsRunInput): Promise<GeneratedFile[]>
|
|
|
74
77
|
}
|
|
75
78
|
}
|
|
76
79
|
|
|
80
|
+
if (shareModels && models.length > 0) {
|
|
81
|
+
const generated = models.filter((m) => m.import == null)
|
|
82
|
+
if (strictSharedModels && generated.length > 0) {
|
|
83
|
+
const list = generated.map((m) => `"${m.id}" (${m.name})`).join(', ')
|
|
84
|
+
throw new Error(
|
|
85
|
+
`[ts-procedures-codegen] --strict-shared-models: ${generated.length} $id-bearing schema(s) have no shared-type mapping and would be generated as local structural twins: ${list}. Add a sharedTypesImport entry, set sharedModelsModule, or drop --strict-shared-models.`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
const reExported = models.length - generated.length
|
|
89
|
+
logger?.(
|
|
90
|
+
`[ts-procedures-codegen] Shared models: ${models.length} total — ${reExported} re-exported, ${generated.length} generated locally.`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
77
94
|
const files: GeneratedFile[] = []
|
|
78
95
|
|
|
79
96
|
for (const group of groups) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
2
|
import { runPipeline } from '../../pipeline.js'
|
|
3
3
|
import type { DocEnvelope } from '../../../implementations/types.js'
|
|
4
4
|
import type { GeneratedFile } from '../_shared/write-files.js'
|
|
@@ -233,6 +233,114 @@ describe('shared models (TS target, ajsc x-named-type)', () => {
|
|
|
233
233
|
expect(messages.code).toContain("import type { Message } from './_models'")
|
|
234
234
|
})
|
|
235
235
|
|
|
236
|
+
it('sharedModelsModule re-exports every $id model from the convention module', async () => {
|
|
237
|
+
const files = await runPipeline({
|
|
238
|
+
envelope: modelEnvelope(),
|
|
239
|
+
outDir: 'out',
|
|
240
|
+
dryRun: true,
|
|
241
|
+
shareModels: true,
|
|
242
|
+
namespaceTypes: true,
|
|
243
|
+
selfContained: false,
|
|
244
|
+
sharedModelsModule: '@app/schemas',
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const modelsFile = findFile(files, '_models.ts')!
|
|
248
|
+
// Re-exported, not generated.
|
|
249
|
+
expect(modelsFile.code).toContain("export { Message } from '@app/schemas'")
|
|
250
|
+
expect(countMatches(modelsFile.code, /export type Message =/g)).toBe(0)
|
|
251
|
+
|
|
252
|
+
// Scopes still import the shared name from ./_models.
|
|
253
|
+
expect(findFile(files, 'messages.ts')!.code).toContain("from './_models'")
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('explicit sharedTypesImport overrides the sharedModelsModule convention per $id', async () => {
|
|
257
|
+
const files = await runPipeline({
|
|
258
|
+
envelope: modelEnvelope(),
|
|
259
|
+
outDir: 'out',
|
|
260
|
+
dryRun: true,
|
|
261
|
+
shareModels: true,
|
|
262
|
+
namespaceTypes: true,
|
|
263
|
+
selfContained: false,
|
|
264
|
+
sharedModelsModule: '@app/schemas',
|
|
265
|
+
sharedTypesImport: { 'urn:msg': { module: '@override/pkg', name: 'Message' } },
|
|
266
|
+
})
|
|
267
|
+
expect(findFile(files, '_models.ts')!.code).toContain("export { Message } from '@override/pkg'")
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('emits a neutral summary of the re-exported / generated split via the injected logger', async () => {
|
|
271
|
+
const lines: string[] = []
|
|
272
|
+
await runPipeline({
|
|
273
|
+
envelope: modelEnvelope(),
|
|
274
|
+
outDir: 'out',
|
|
275
|
+
dryRun: true,
|
|
276
|
+
shareModels: true,
|
|
277
|
+
namespaceTypes: true,
|
|
278
|
+
selfContained: false,
|
|
279
|
+
logger: (m) => lines.push(m),
|
|
280
|
+
})
|
|
281
|
+
expect(lines.join('\n')).toContain('Shared models: 1 total — 0 re-exported, 1 generated locally.')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('does not emit a summary when there are no $id-bearing models', async () => {
|
|
285
|
+
const lines: string[] = []
|
|
286
|
+
await runPipeline({
|
|
287
|
+
envelope: noModelEnvelope(),
|
|
288
|
+
outDir: 'out',
|
|
289
|
+
dryRun: true,
|
|
290
|
+
shareModels: true,
|
|
291
|
+
namespaceTypes: true,
|
|
292
|
+
selfContained: false,
|
|
293
|
+
logger: (m) => lines.push(m),
|
|
294
|
+
})
|
|
295
|
+
expect(lines.join('\n')).not.toContain('Shared models:')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('stays silent (no console output) when no logger is injected', async () => {
|
|
299
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
300
|
+
try {
|
|
301
|
+
await runPipeline({
|
|
302
|
+
envelope: modelEnvelope(),
|
|
303
|
+
outDir: 'out',
|
|
304
|
+
dryRun: true,
|
|
305
|
+
shareModels: true,
|
|
306
|
+
namespaceTypes: true,
|
|
307
|
+
selfContained: false,
|
|
308
|
+
})
|
|
309
|
+
expect(log.mock.calls.flat().join('\n')).not.toContain('Shared models:')
|
|
310
|
+
} finally {
|
|
311
|
+
log.mockRestore()
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('strictSharedModels throws listing the offending $id and derived name', async () => {
|
|
316
|
+
await expect(
|
|
317
|
+
runPipeline({
|
|
318
|
+
envelope: modelEnvelope(),
|
|
319
|
+
outDir: 'out',
|
|
320
|
+
dryRun: true,
|
|
321
|
+
shareModels: true,
|
|
322
|
+
namespaceTypes: true,
|
|
323
|
+
selfContained: false,
|
|
324
|
+
strictSharedModels: true,
|
|
325
|
+
}),
|
|
326
|
+
).rejects.toThrow(/--strict-shared-models[\s\S]*urn:msg[\s\S]*Message/)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('strictSharedModels passes once every model is covered by the convention', async () => {
|
|
330
|
+
await expect(
|
|
331
|
+
runPipeline({
|
|
332
|
+
envelope: modelEnvelope(),
|
|
333
|
+
outDir: 'out',
|
|
334
|
+
dryRun: true,
|
|
335
|
+
shareModels: true,
|
|
336
|
+
namespaceTypes: true,
|
|
337
|
+
selfContained: false,
|
|
338
|
+
strictSharedModels: true,
|
|
339
|
+
sharedModelsModule: '@app/schemas',
|
|
340
|
+
}),
|
|
341
|
+
).resolves.toBeDefined()
|
|
342
|
+
})
|
|
343
|
+
|
|
236
344
|
it('fails loudly when a shared model name collides with a property-derived sub-type', async () => {
|
|
237
345
|
// `latest` references the Message model; a sibling property literally named
|
|
238
346
|
// `message` would make ajsc extract a structural sub-type ALSO named
|