From 49d6f1e624b8f1097da62cc9771e131c9e44753f Mon Sep 17 00:00:00 2001 From: Vasek - Tom C Date: Wed, 9 Sep 2026 14:25:26 +0200 Subject: [PATCH 1/5] fix: give a module its own API back, so it can call itself A module's bindings carried core and its dependencies but not itself, so `dag.()` did not exist and the self call was gone. The Go SDK has it: internal/dagger/.gen.go, beside the dependency's. The schema a module's bindings are generated from is core plus dependencies -- at generation time the module is defined by source that has not been compiled, so its own API cannot be in it. The schema that does carry it is the module's own client-facing one, and reading that means loading the module, which needs the bindings that do not exist yet. So generation runs in two passes: bindings, entrypoint and config as before, then the bindings again with the module's own API folded in. Both passes run every time from the same inputs, so the result does not depend on what was on disk when the run started -- without that, a fresh module's first generation had no self bindings and the second did, and `dagger generate` was no longer idempotent. Only the bindings are redone; the entrypoint comes from scanning the user's source, which the self types do not change, and is the expensive half. Folding self needs its own path. Include selects a module's types by their type-level sourceMap directive, and a module's own types do not carry one in its own client schema -- only the Query field that reaches them does. So the fields come from Include and the types come from walking out of them. Also stop hiding generated files from git in the fixtures that are CLI 1.0 scopes. The engine cannot load a module whose committed files are ignored, so a dependent -- or the module itself, now -- fails with "committed generated file is missing" while the file sits right there. That is what the error means by "commit every generated file", and it made `dagger generate` fail on every run after the first. Signed-off-by: Tom Chauveau --- .dagger/modules/e2e/generate.dang | 22 +++++++++++ dagger.lock | 5 +++ dagger.toml | 4 +- helpers/codegen/main.go | 63 +++++++++++++++++++++++++++++++ typescript-sdk.dang | 58 ++++++++++++++++++++++++++-- 5 files changed, 146 insertions(+), 6 deletions(-) diff --git a/.dagger/modules/e2e/generate.dang b/.dagger/modules/e2e/generate.dang index 671dab6..0506590 100644 --- a/.dagger/modules/e2e/generate.dang +++ b/.dagger/modules/e2e/generate.dang @@ -255,6 +255,28 @@ type GenerateChecks { null } + """ + A module's bindings should carry its own API, so it can call itself. + + The schema a module's bindings come from is core plus its dependencies: at + generation time the module is defined by source that has not been compiled, so + its own API is not in it. Reading the schema that does carry it means loading + the module, which needs the bindings — so this is generated in a second pass, + and the check is that the pass happens at all. + """ + generateSelfCallCheck(ws: Workspace!): Void @check { + let path = fixtures.depLibModule + let changes = Gen.module(ws, path, "gendep") + + Asserts.stringContains( + changes.after.directory(path).file("sdk/client.gen.ts").contents, + "gendep", + "a module's bindings should expose its own API for the self call", + ) + + null + } + """ Every scope recorded in dagger.toml should generate its own files. diff --git a/dagger.lock b/dagger.lock index 0c8eba4..af7c6a1 100644 --- a/dagger.lock +++ b/dagger.lock @@ -1,3 +1,6 @@ +# Generated by Dagger. Pins the images and git refs this workspace uses. +# Any Dagger command may update this file; always commit it with your changes. +# Run `dagger workspace update` to refresh the pinned versions. [["version","2"]] ["","git-latest",["https://github.com/dagger/sdk-helpers"],"refs/heads/main"] ["","git-sha",["https://github.com/containernetworking/plugins","refs/tags/v1.9.0"],"9b3772e1a7abf93cbb7c6526a28bc0d27b830e02"] @@ -5,9 +8,11 @@ ["","git-sha",["https://github.com/libfuse/sshfs.git","refs/tags/sshfs-3.7.6"],"7a2d988775446ebe7af9b01c99b3b8e86bddb05a"] ["","git-sha",["https://github.com/opencontainers/runc","refs/tags/v1.4.2"],"c241c0bb5e60a8e8c1b2e53d4eca8d0068d8d57e"] ["","oci-latest",["docker.io/library/busybox"],"1.38.0"] +["","oci-sha",["docker.io/denoland/deno:alpine-2.0.0"],"sha256:0a6f0116409d2330af10a83b9a66c7c5732530e0bee5823627221b86e4f62c5e"] ["","oci-sha",["docker.io/library/alpine:3.22"],"sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce"] ["","oci-sha",["docker.io/library/busybox:1.38.0"],"sha256:dc2d74b28e4cf8984fa52af1f39bc7c3d9c73760b41a74d629f5d11b1ab28616"] ["","oci-sha",["docker.io/library/golang:1.25-alpine"],"sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59"] ["","oci-sha",["docker.io/library/golang:1.26-alpine"],"sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468"] ["","oci-sha",["docker.io/library/node:22-alpine"],"sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32"] +["","oci-sha",["docker.io/library/node:23.2.0-alpine"],"sha256:ecefaffd4706c5879af52e022fdb8ea30cbd6590e2a30d05347790d690727c6c"] ["","oci-sha",["docker.io/tonistiigi/xx:1.2.1"],"sha256:8879a398dedf0aadaacfbd332b29ff2f84bc39ae6d4e9c0a1109db27ac5ba012"] \ No newline at end of file diff --git a/dagger.toml b/dagger.toml index 19a3a3d..dd79e57 100644 --- a/dagger.toml +++ b/dagger.toml @@ -43,7 +43,7 @@ module = "typescript-sdk" [sdks.typescript.scopes.".dagger/modules/e2e/fixtures/client/out"] clients = [ - ".dagger/modules/e2e/fixtures/client/app", + "./.dagger/modules/e2e/fixtures/client/app", ] [sdks.typescript.scopes.".dagger/modules/e2e/fixtures/generate-deno/app"] @@ -54,7 +54,7 @@ name = "generate-deno-app" is-module = true name = "generate-deps-app" clients = [ - ".dagger/modules/e2e/fixtures/generate-deps/dep", + "./.dagger/modules/e2e/fixtures/generate-deps/dep", ] [sdks.typescript.scopes.".dagger/modules/e2e/fixtures/generate-deps/dep"] diff --git a/helpers/codegen/main.go b/helpers/codegen/main.go index f002351..0685d83 100644 --- a/helpers/codegen/main.go +++ b/helpers/codegen/main.go @@ -47,9 +47,12 @@ type clientMeta struct { } // clientMetaModule is one bound module plus the path its schema was staged at. +// Self marks the module the bindings are being generated for, which is folded in +// differently (see foldClientsIntoModuleSchema). type clientMetaModule struct { generator.BoundModule SchemaPath string `json:"schemaPath"` + Self bool `json:"self,omitempty"` } // validateBoundModuleKind fails closed on a source kind the generated client @@ -275,6 +278,10 @@ func foldClientsIntoModuleSchema( if err != nil { return nil, fmt.Errorf("client %q: %w", module.Name, err) } + if module.Self { + schemas = append(schemas, selfContribution(clientSchema, module.Name)) + continue + } // Ask the schema which modules it carries rather than trusting the // recorded name: the split is driven by source-map directives, and a // target's directive name is the only one the filter matches. @@ -429,3 +436,59 @@ func loadSchema(path string) (*introspection.Schema, string, error) { return resp.Schema, resp.SchemaVersion, nil } + +// selfContribution extracts what a module contributes to its own client schema: +// the entry points on the extendable types, plus the types those reach. +// +// Include cannot do this alone. It selects a module's types by their type-level +// sourceMap directive, and a module's own types do not carry one in its own +// client schema — only the Query field that reaches them does. So the fields +// come from Include and the types come from walking out of them. +// +// The walk is bounded by what the merge does with the result: mergeSchemas only +// adds types the module-facing schema lacks, so anything core the walk passes +// through is already there and is dropped. +func selfContribution(schema *introspection.Schema, moduleName string) *introspection.Schema { + contributed := schema.Include(moduleName) + + byName := map[string]*introspection.Type{} + for _, typ := range schema.Types { + byName[typ.Name] = typ + } + + seen := map[string]bool{} + var walk func(ref *introspection.TypeRef) + walk = func(ref *introspection.TypeRef) { + for ; ref != nil; ref = ref.OfType { + if ref.Name == "" || seen[ref.Name] { + continue + } + typ, ok := byName[ref.Name] + if !ok { + continue + } + seen[ref.Name] = true + contributed.Types = append(contributed.Types, typ) + for _, field := range typ.Fields { + walk(field.TypeRef) + for _, arg := range field.Args { + walk(arg.TypeRef) + } + } + for _, input := range typ.InputFields { + walk(input.TypeRef) + } + } + } + + for _, typ := range contributed.Types { + for _, field := range typ.Fields { + walk(field.TypeRef) + for _, arg := range field.Args { + walk(arg.TypeRef) + } + } + } + + return contributed +} diff --git a/typescript-sdk.dang b/typescript-sdk.dang index e058822..7f0325d 100644 --- a/typescript-sdk.dang +++ b/typescript-sdk.dang @@ -717,7 +717,8 @@ type TypescriptSdk { ",\"kind\":" + target.kindJSON + ",\"path\":" + JSON.encode(target.path) + ",\"ref\":" + JSON.encode(target.ref) + - ",\"pin\":" + JSON.encode(target.pin) + "}" + ",\"pin\":" + JSON.encode(target.pin) + + ",\"self\":" + (if (target.isSelf) { "true" } else { "false" }) + "}" }.join(",") targets @@ -748,6 +749,7 @@ type TypescriptSdk { path: module.sourceRootSubpath, ref: module.asString, pin: module.pin, + isSelf: false, ) } } @@ -902,13 +904,13 @@ type TypescriptSdk { """ let moduleFiles(ws: Workspace!, scope: String!, sourcePath: String!, name: String!, rt: Runtime!, targets: [ClientTarget!]!): Workspace! { let sourcePrefix = if (sourcePath == ".") { "" } else { sourcePath + "/" } - let modSrc = ws.moduleSource("/" + scope) + let schemaJSON = ws.moduleSource("/" + scope).introspectionSchemaJSON.contents let existing = existingDir(ws, sourcePath) .withoutFiles(moduleBindings(ws, sourcePath).map { entry => "sdk/" + entry }) let generated = moduleDirectory( - modSrc.introspectionSchemaJSON.contents, + schemaJSON, name, ws.directory("/" + sourcePrefix + "src"), existingModuleConfig(ws, sourcePath), @@ -920,7 +922,49 @@ type TypescriptSdk { # it. withNewDirectory swaps the whole directory, so writing only what we # generate would say everything else — the module's config and its source — # had been deleted. - ws.withNewDirectory("/" + sourcePath, existing.withDirectory(".", generated)) + let staged = ws.withNewDirectory("/" + sourcePath, existing.withDirectory(".", generated)) + + # Second pass, for the self call. A module's own API is not in the schema its + # bindings come from, and reading the schema that does carry it means loading + # the module — which needs the files the first pass just wrote. Both passes + # run every time, from the same inputs, so the result does not depend on what + # was on disk when the run started. + let selfTarget = selfClientTarget(staged, scope, name) + if (selfTarget.length == 0) { + staged + } else { + # Only the bindings are redone. The entrypoint comes from scanning the + # user's source, which the self types do not change, and it is the + # expensive half. + staged.withNewDirectory( + "/" + moduleSdkPath(sourcePath), + moduleSdkDirectory(generateModuleBindings(schemaJSON, name, targets + selfTarget)), + ) + } + } + + """ + The module itself, as a target to fold into its own bindings — or nothing, + when it cannot be loaded yet. + + Reading a module's client-facing schema means loading it, and a module built + from committed files cannot be loaded until they exist. So the first + generation of a brand-new module has no self bindings and the one after it + does. That is the same shape the engine's own generators have: a module that + could not load before a run loads with the run's changes applied. + """ + let selfClientTarget(ws: Workspace!, scope: String!, name: String!): [ClientTarget!]! { + let schema = ws.moduleSource("/" + scope).clientSchemaIntrospectionJSON.contents rescue { + err: Error => "" + } + + if (schema == "") { + [] + } else { + # Never a dependency and never in the standalone package: a module does not + # depend on itself, and the engine already serves it into its own session. + [ClientTarget(name: name, schemaJSON: schema, kindJSON: "\"\"", isLocal: true, path: scope, ref: "", pin: "", isSelf: true)] + } } """ @@ -1136,6 +1180,12 @@ type ClientTarget { """ let isLocal: Boolean! + """ + Whether this target is the module being generated. Its own types are folded + into its bindings differently, and it is never a dependency or a package entry. + """ + let isSelf: Boolean! + """ Workspace-root-relative path, for local kinds. """ From 05ea915ec6b16f17a764b80e7c64ceb486ec234d Mon Sep 17 00:00:00 2001 From: Vasek - Tom C Date: Wed, 9 Sep 2026 17:30:42 +0200 Subject: [PATCH 2/5] fix: stop hiding a module's generated files from git A CLI 1.0 module is built from committed generated files, and the engine does not see files git ignores. So a fixture that ignored its own sdk/ could not be loaded once those files existed on disk: a dependent failed with "committed generated file sdk/client.gen.ts is missing" while the file sat right there, and so did the module itself, now that generating it reads its own schema. The failure only appears after a first successful run, which is why it looked like flaky state: on a clean tree the files are written into the workspace the generator threads along, and everything resolves. Only the ignore rules change. The generated output stays untracked, which is what the fixtures that never ignored it already do -- committing it would also pin the typescript version in the one fixture that has to start unpinned for generate-typescript-pin-check to mean anything. Signed-off-by: Tom Chauveau --- .dagger/modules/e2e/fixtures/generate-deps/app/.gitignore | 1 - .dagger/modules/e2e/fixtures/generate-deps/dep/.gitignore | 1 - .dagger/modules/runtimes/fixtures/bun/.gitignore | 3 --- .dagger/modules/runtimes/fixtures/deno/.gitignore | 3 --- .dagger/modules/runtimes/fixtures/node/.gitignore | 3 --- 5 files changed, 11 deletions(-) diff --git a/.dagger/modules/e2e/fixtures/generate-deps/app/.gitignore b/.dagger/modules/e2e/fixtures/generate-deps/app/.gitignore index 040187c..b71ab2d 100644 --- a/.dagger/modules/e2e/fixtures/generate-deps/app/.gitignore +++ b/.dagger/modules/e2e/fixtures/generate-deps/app/.gitignore @@ -1,4 +1,3 @@ -/sdk /**/node_modules/** /**/.pnpm-store/** /.env diff --git a/.dagger/modules/e2e/fixtures/generate-deps/dep/.gitignore b/.dagger/modules/e2e/fixtures/generate-deps/dep/.gitignore index 040187c..b71ab2d 100644 --- a/.dagger/modules/e2e/fixtures/generate-deps/dep/.gitignore +++ b/.dagger/modules/e2e/fixtures/generate-deps/dep/.gitignore @@ -1,4 +1,3 @@ -/sdk /**/node_modules/** /**/.pnpm-store/** /.env diff --git a/.dagger/modules/runtimes/fixtures/bun/.gitignore b/.dagger/modules/runtimes/fixtures/bun/.gitignore index 5b4e8f0..621ed55 100644 --- a/.dagger/modules/runtimes/fixtures/bun/.gitignore +++ b/.dagger/modules/runtimes/fixtures/bun/.gitignore @@ -1,5 +1,2 @@ -/sdk -/__dagger.entrypoint.ts -/tsconfig.json /**/node_modules/** /.env diff --git a/.dagger/modules/runtimes/fixtures/deno/.gitignore b/.dagger/modules/runtimes/fixtures/deno/.gitignore index 5b4e8f0..621ed55 100644 --- a/.dagger/modules/runtimes/fixtures/deno/.gitignore +++ b/.dagger/modules/runtimes/fixtures/deno/.gitignore @@ -1,5 +1,2 @@ -/sdk -/__dagger.entrypoint.ts -/tsconfig.json /**/node_modules/** /.env diff --git a/.dagger/modules/runtimes/fixtures/node/.gitignore b/.dagger/modules/runtimes/fixtures/node/.gitignore index 5b4e8f0..621ed55 100644 --- a/.dagger/modules/runtimes/fixtures/node/.gitignore +++ b/.dagger/modules/runtimes/fixtures/node/.gitignore @@ -1,5 +1,2 @@ -/sdk -/__dagger.entrypoint.ts -/tsconfig.json /**/node_modules/** /.env From cf270aa8350dc734c5e71cb3851f13ecd0098db8 Mon Sep 17 00:00:00 2001 From: Vasek - Tom C Date: Wed, 9 Sep 2026 18:06:06 +0200 Subject: [PATCH 3/5] fix: render a module's own API like any other module's Two things made a module's own bindings the odd one out. They lived in client.gen.ts while every other module's went to its own file. Nothing distinguishes them: a module reaches its own API through the same client it reaches a dependency's, so the one binding a reader goes looking for was the only one not where the others are. Every module in the schema now splits, the one being generated for included. Splitting it needs the types attributed to it, and a module's own types carry no sourceMap directive in its own client schema -- only the Query field that reaches them does. So the walk that collects them now stamps them, which is what lets the existing split, re-export and augmentation machinery treat them like a dependency's. The module-facing schema is what separates the module's own types from the core ones the walk passes through: core is already there. And the accessor was PascalCase. Schema fields are camelCase by convention, so this only showed on a module whose name starts with an underscore -- the leading underscore survives into the field name, and dag.TestSdkMaxDev() was the one member of the API not shaped like the rest. Member names are now lowered at the sites that render a schema field as a TypeScript member; the wire name is rendered from the raw field, so this changes what the caller writes, not what is selected. Signed-off-by: Tom Chauveau --- .../generator/typescript/dep_split_test.go | 13 +++---- .../codegen/generator/typescript/generator.go | 21 ++++------- .../generator/typescript/module_test.go | 14 ++++--- .../typescript/templates/functions.go | 17 +++++++++ .../typescript/templates/functions_test.go | 21 ++++++++++- .../templates/src/_augmentations.ts.gtpl | 4 +- .../typescript/templates/src/method.ts.gtpl | 2 +- helpers/codegen/main.go | 37 +++++++++++++++---- 8 files changed, 91 insertions(+), 38 deletions(-) diff --git a/helpers/codegen/generator/typescript/dep_split_test.go b/helpers/codegen/generator/typescript/dep_split_test.go index ab4a4c1..32a89aa 100644 --- a/helpers/codegen/generator/typescript/dep_split_test.go +++ b/helpers/codegen/generator/typescript/dep_split_test.go @@ -297,7 +297,7 @@ func TestGenerate_SplitsDependencyFiles(t *testing.T) { // TestGenerate_KeepsOwnTypesInClient checks that only dependencies are split: // the module being generated for keeps its own types in client.gen.ts. -func TestGenerate_KeepsOwnTypesInClient(t *testing.T) { +func TestGenerate_SplitsOwnTypesLikeADependency(t *testing.T) { appModule := newSourceMapDirective("app") depModule := newSourceMapDirective("dep") strField := func(name string) *introspection.Field { @@ -326,15 +326,14 @@ func TestGenerate_KeepsOwnTypesInClient(t *testing.T) { core := readOverlay(t, state, "client.gen.ts") depFile := readOverlay(t, state, "dep.gen.ts") - // The module's own type stays in client.gen.ts (not split out). - require.Contains(t, core, "export class App extends BaseClient") + // The module's own type splits out like any other, and the core file holds + // only core types plus the re-exports. + require.NotContains(t, core, "export class App extends BaseClient") require.Contains(t, core, `export * from "./dep.gen.js"`) - require.NotContains(t, core, `export * from "./app.gen.js"`) + require.Contains(t, core, `export * from "./app.gen.js"`) - // Only the dependency gets its own file. require.Contains(t, depFile, "export class Dep extends BaseClient") - _, err = state.Overlay.Open("app.gen.ts") - require.Error(t, err, "the module's own types must not be split into app.gen.ts") + require.Contains(t, readOverlay(t, state, "app.gen.ts"), "export class App extends BaseClient") } // TestGenerate_Client_SplitsBoundModule checks the standalone-client layout: diff --git a/helpers/codegen/generator/typescript/generator.go b/helpers/codegen/generator/typescript/generator.go index dc6857c..ff51ee8 100644 --- a/helpers/codegen/generator/typescript/generator.go +++ b/helpers/codegen/generator/typescript/generator.go @@ -73,18 +73,13 @@ func generate(config generator.Config, target string, schema *introspection.Sche // fields are dropped and re-attached as prototype augmentations in each // per-module file. // - // A standalone client splits *every* module — including the one it binds — - // so the bound module (e.g. hello) lands in hello.gen.ts and the core file - // (dagger.gen.ts) holds only core types. Module codegen keeps the module's - // own types in the core file (client.gen.ts) and splits only dependencies. - selfModule := selfModuleName(config) - var splitModules []string - if config.ClientConfig != nil { - splitModules = schema.DependencyNames() - selfModule = "" - } else { - splitModules = templates.DependencyModules(schema, selfModule) - } + // Every module in the schema is split, including the one being generated + // for: its own API lands in .gen.ts beside its dependencies', and + // the core file holds only core types. Nothing distinguishes a module's own + // API from a dependency's here — it is reached through the same client, and + // keeping it in the core file would make the one binding a reader goes + // looking for the only one not where the others are. + splitModules := schema.DependencyNames() coreSchema := schema if len(splitModules) > 0 { @@ -94,7 +89,7 @@ func generate(config generator.Config, target string, schema *introspection.Sche // The template funcs always get the full schema so the module-splitting // helpers can enumerate modules regardless of which (possibly filtered) // schema a given file is rendered from. - tmpl := templates.New(schemaVersion, schema, selfModule, config) + tmpl := templates.New(schemaVersion, schema, "", config) mfs := memfs.New() diff --git a/helpers/codegen/generator/typescript/module_test.go b/helpers/codegen/generator/typescript/module_test.go index 300912d..df0686f 100644 --- a/helpers/codegen/generator/typescript/module_test.go +++ b/helpers/codegen/generator/typescript/module_test.go @@ -69,13 +69,15 @@ func TestGenerateModule_Layout(t *testing.T) { require.NotContains(t, core, `from "@dagger.io/dagger"`) require.Contains(t, dep, `from "./core.js"`) - // The module's own types stay in the core file; only dependencies split out. - require.Contains(t, core, "export class App extends BaseClient") + // Every module splits into its own file, the module being generated for + // included: its API is reached through the same client as a dependency's, so + // keeping it in the core file would put the one binding a reader goes looking + // for in the only place the others are not. require.Contains(t, core, `export * from "./gendep.gen.js"`) + require.Contains(t, core, `export * from "./app.gen.js"`) require.Contains(t, dep, "export class Gendep extends BaseClient") - - _, err = state.Overlay.Open("app.gen.ts") - require.Error(t, err, "the module's own types must not be split into app.gen.ts") + require.NotContains(t, core, "export class App extends BaseClient") + require.Contains(t, readOverlay(t, state, "app.gen.ts"), "export class App extends BaseClient") } // TestGenerateModule_SourceMapPathIsRelativeToSDKDir pins the source-map @@ -90,7 +92,7 @@ func TestGenerateModule_SourceMapPathIsRelativeToSDKDir(t *testing.T) { state, err := gen.GenerateModule(context.Background(), moduleSchema(t), "v0.21.0") require.NoError(t, err) - require.Contains(t, readOverlay(t, state, "client.gen.ts"), "// app (../src/index.ts:12:0)") + require.Contains(t, readOverlay(t, state, "app.gen.ts"), "// app (../src/index.ts:12:0)") } // TestGenerateLibrary_ImportsRuntimeFromSource covers the third import arm: the diff --git a/helpers/codegen/generator/typescript/templates/functions.go b/helpers/codegen/generator/typescript/templates/functions.go index ead66e8..ee8dd41 100644 --- a/helpers/codegen/generator/typescript/templates/functions.go +++ b/helpers/codegen/generator/typescript/templates/functions.go @@ -9,6 +9,7 @@ import ( "sort" "strings" "text/template" + "unicode" "golang.org/x/mod/semver" @@ -113,6 +114,7 @@ func (funcs typescriptTemplateFuncs) FuncMap() template.FuncMap { "FormatOutputType": commonFunc.FormatOutputType, "FormatEnum": funcs.formatEnum, "FormatName": funcs.formatName, + "FormatMemberName": funcs.formatMemberName, "QueryToClient": funcs.queryToClient, "GetOptionalArgs": funcs.getOptionalArgs, "GetRequiredArgs": funcs.getRequiredArgs, @@ -424,6 +426,21 @@ func (funcs typescriptTemplateFuncs) formatName(s string) string { return s } +// formatMemberName renders a schema field as a TypeScript member. Fields are +// camelCase by convention and this is a no-op for them, but a module whose name +// starts with an underscore gets a capitalised field, and the binding for it +// would then be the one member of the API not shaped like the rest. The wire +// name is rendered separately from the raw field, so lowering the first letter +// here changes what the caller writes, not what is selected. +func (funcs typescriptTemplateFuncs) formatMemberName(s string) string { + if s == "" { + return s + } + runes := []rune(s) + runes[0] = unicode.ToLower(runes[0]) + return funcs.formatName(string(runes)) +} + func (funcs typescriptTemplateFuncs) queryToClient(s string) string { if s == generator.QueryStructName { return generator.QueryStructClientName diff --git a/helpers/codegen/generator/typescript/templates/functions_test.go b/helpers/codegen/generator/typescript/templates/functions_test.go index 8bdb94a..c38ad7d 100644 --- a/helpers/codegen/generator/typescript/templates/functions_test.go +++ b/helpers/codegen/generator/typescript/templates/functions_test.go @@ -1,6 +1,10 @@ package templates -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/require" +) // TestToPascalCase guards the acronym handling that strcase.ToCamel gets wrong: // enum converter functions are defined via pascalCase but called by their raw @@ -21,3 +25,18 @@ func TestToPascalCase(t *testing.T) { } } } + +// TestFormatMemberName covers the one case it exists for: a module whose name +// starts with an underscore gets a capitalised schema field, and its binding +// would otherwise be the only member of the API not shaped like the rest. +func TestFormatMemberName(t *testing.T) { + funcs := typescriptTemplateFuncs{} + + require.Equal(t, "testSdkMaxDev", funcs.formatMemberName("TestSdkMaxDev")) + // Already conventional: unchanged. + require.Equal(t, "helloWorld", funcs.formatMemberName("helloWorld")) + require.Equal(t, "gendep", funcs.formatMemberName("gendep")) + // Keyword handling still applies, after lowering. + require.Equal(t, "function_", funcs.formatMemberName("Function")) + require.Equal(t, "", funcs.formatMemberName("")) +} diff --git a/helpers/codegen/generator/typescript/templates/src/_augmentations.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/_augmentations.ts.gtpl index 3fa972b..cba6685 100644 --- a/helpers/codegen/generator/typescript/templates/src/_augmentations.ts.gtpl +++ b/helpers/codegen/generator/typescript/templates/src/_augmentations.ts.gtpl @@ -90,7 +90,7 @@ the `interface X { ... }` block. The dot is an introspection.Field. */ -}} {{- $optionals := GetOptionalArgs .Args -}} {{- $parentName := .ParentObject.Name -}} {{- if eq $parentName "Query" }}{{ $parentName = "Client" }}{{ end -}} - {{ .Name | FormatName }}( + {{ .Name | FormatMemberName }}( {{- if $required }}{{ template "args" . }}{{ end -}} {{- if $optionals -}} {{- if $required }}, {{ end }}opts?: {{ $parentName }}{{ .Name | PascalCase }}Opts @@ -111,7 +111,7 @@ ESM cycle). The body is shared with the class-field methods. */ -}} {{- $optionals := GetOptionalArgs $field.Args -}} {{- $parentName := $field.ParentObject.Name -}} {{- if eq $parentName "Query" }}{{ $parentName = "Client" }}{{ end -}} -{{ $parent }}.prototype.{{ $field.Name | FormatName }} = {{ if Solve $field }}async {{ end }}function (this: any +{{ $parent }}.prototype.{{ $field.Name | FormatMemberName }} = {{ if Solve $field }}async {{ end }}function (this: any {{- /* `this: any` is always the first param, so required args and opts each always need a leading comma. */ -}} {{- if $required -}}, {{ template "args" $field }}{{- end -}} diff --git a/helpers/codegen/generator/typescript/templates/src/method.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/method.ts.gtpl index 7c036bc..0a7e9e9 100644 --- a/helpers/codegen/generator/typescript/templates/src/method.ts.gtpl +++ b/helpers/codegen/generator/typescript/templates/src/method.ts.gtpl @@ -12,7 +12,7 @@ {{- template "method_comment" . }} {{- /* Write method name. */ -}} - {{- "" }} {{ .Name | FormatName }} = ( + {{- "" }} {{ .Name | FormatMemberName }} = ( {{- /* Write required arguments. */ -}} {{- if $required }} diff --git a/helpers/codegen/main.go b/helpers/codegen/main.go index 0685d83..ce9ae42 100644 --- a/helpers/codegen/main.go +++ b/helpers/codegen/main.go @@ -279,7 +279,7 @@ func foldClientsIntoModuleSchema( return nil, fmt.Errorf("client %q: %w", module.Name, err) } if module.Self { - schemas = append(schemas, selfContribution(clientSchema, module.Name)) + schemas = append(schemas, selfContribution(clientSchema, module.Name, schema)) continue } // Ask the schema which modules it carries rather than trusting the @@ -445,10 +445,13 @@ func loadSchema(path string) (*introspection.Schema, string, error) { // client schema — only the Query field that reaches them does. So the fields // come from Include and the types come from walking out of them. // -// The walk is bounded by what the merge does with the result: mergeSchemas only -// adds types the module-facing schema lacks, so anything core the walk passes -// through is already there and is dropped. -func selfContribution(schema *introspection.Schema, moduleName string) *introspection.Schema { +// `base` is the module-facing schema, and it is what separates the module's own +// types from the core ones the walk passes through: core is already in base, so +// anything the walk finds that base does not have belongs to the module. Those +// get the sourceMap directive stamped on, which is what lets everything +// downstream — the split, the re-exports, the augmentations — treat a module's +// own API exactly like a dependency's and render it into its own file. +func selfContribution(schema *introspection.Schema, moduleName string, base *introspection.Schema) *introspection.Schema { contributed := schema.Include(moduleName) byName := map[string]*introspection.Type{} @@ -463,12 +466,14 @@ func selfContribution(schema *introspection.Schema, moduleName string) *introspe if ref.Name == "" || seen[ref.Name] { continue } + seen[ref.Name] = true typ, ok := byName[ref.Name] - if !ok { + if !ok || base.Types.Get(ref.Name) != nil { + // Core, or already the module's own: either way not something + // this module contributes. continue } - seen[ref.Name] = true - contributed.Types = append(contributed.Types, typ) + contributed.Types = append(contributed.Types, stampSourceMap(typ, moduleName)) for _, field := range typ.Fields { walk(field.TypeRef) for _, arg := range field.Args { @@ -492,3 +497,19 @@ func selfContribution(schema *introspection.Schema, moduleName string) *introspe return contributed } + +// stampSourceMap returns a copy of typ marked as belonging to moduleName. The +// copy matters: the type is shared with the schema it was read from, and the +// directive changes how it is filtered. +func stampSourceMap(typ *introspection.Type, moduleName string) *introspection.Type { + if typ.Directives.SourceMap() != nil { + return typ + } + value := `"` + moduleName + `"` + stamped := *typ + stamped.Directives = append(introspection.Directives{{ + Name: "sourceMap", + Args: []*introspection.DirectiveArg{{Name: "module", Value: &value}}, + }}, typ.Directives...) + return &stamped +} From 3ee96da12faddc6baf1f1aa6f949514dd4c32b76 Mon Sep 17 00:00:00 2001 From: Vasek - Tom C Date: Wed, 9 Sep 2026 18:32:19 +0200 Subject: [PATCH 4/5] fix: render a module into its own standalone package too A module's own API was folded into its bindings but left out of the package under clients/, so the one module a caller is most likely to want from that package -- the one it sits inside -- was the only one missing from it. It goes in as a local target like any other: the generated bootstrap serves it by resolving the scope against the workspace, so a caller outside the module reaches it the same way it reaches a target that was added on purpose. Still never a manifest dependency, because a module does not depend on itself. The self target is now resolved once, in generateScope between the two passes, rather than inside the first: it is not available until that pass has written the files that make the module loadable, and both halves need it. Signed-off-by: Tom Chauveau --- .dagger/modules/e2e/generate.dang | 24 +++++++-- typescript-sdk.dang | 84 ++++++++++++++++++++++--------- 2 files changed, 80 insertions(+), 28 deletions(-) diff --git a/.dagger/modules/e2e/generate.dang b/.dagger/modules/e2e/generate.dang index 0506590..4bac566 100644 --- a/.dagger/modules/e2e/generate.dang +++ b/.dagger/modules/e2e/generate.dang @@ -267,11 +267,29 @@ type GenerateChecks { generateSelfCallCheck(ws: Workspace!): Void @check { let path = fixtures.depLibModule let changes = Gen.module(ws, path, "gendep") + let tree = changes.after.directory(path) + + # Its own file, like any other module's, and re-exported so the module's + # source reaches it through @dagger.io/dagger. + Asserts.generated(changes, path + "/sdk/gendep.gen.ts") + Asserts.stringContains( + tree.file("sdk/client.gen.ts").contents, + "./gendep.gen.js", + "a module's bindings should re-export its own API", + ) + Asserts.stringContains( + tree.file("sdk/gendep.gen.ts").contents, + "gendep()", + "the self accessor should be camelCase like every other member", + ) + # And in the standalone package, so callers outside the module reach it the + # same way they reach a target that was added on purpose. + Asserts.generated(changes, path + "/clients/gendep.gen.ts") Asserts.stringContains( - changes.after.directory(path).file("sdk/client.gen.ts").contents, - "gendep", - "a module's bindings should expose its own API for the self call", + changes.after.directory(path).file("clients/dagger.gen.ts").contents, + path, + "the package's bootstrap should serve the module itself", ) null diff --git a/typescript-sdk.dang b/typescript-sdk.dang index 7f0325d..624df5f 100644 --- a/typescript-sdk.dang +++ b/typescript-sdk.dang @@ -163,10 +163,23 @@ type TypescriptSdk { # in the schema the bindings are generated from. let served = withClientDependencies(configured, scope, source, targets) + # First pass. The module is loadable afterwards, which is what the self + # target needs. + let staged = moduleFiles(served, scope, source, name, rt, targets) + # The module's own API is a target like any other from here on: folded into + # its bindings so its source can call itself, and rendered into the + # standalone package so callers outside it can too. + let selfTargets = selfClientTarget(staged, scope, name) + let withSelf = targets + selfTargets + # Back to the cwd the engine set: it rejects a generateScope that returns # a workspace standing somewhere else. - clientScope(moduleFiles(served, scope, source, name, rt, targets), source, true, targets) - .withWorkdir(scope) + clientScope( + withSelfBindings(staged, scope, source, name, withSelf, selfTargets), + source, + true, + withSelf, + ).withWorkdir(scope) } else { clientScope(ws, scope, false, clientTargets(clients)) } @@ -904,13 +917,12 @@ type TypescriptSdk { """ let moduleFiles(ws: Workspace!, scope: String!, sourcePath: String!, name: String!, rt: Runtime!, targets: [ClientTarget!]!): Workspace! { let sourcePrefix = if (sourcePath == ".") { "" } else { sourcePath + "/" } - let schemaJSON = ws.moduleSource("/" + scope).introspectionSchemaJSON.contents let existing = existingDir(ws, sourcePath) .withoutFiles(moduleBindings(ws, sourcePath).map { entry => "sdk/" + entry }) let generated = moduleDirectory( - schemaJSON, + ws.moduleSource("/" + scope).introspectionSchemaJSON.contents, name, ws.directory("/" + sourcePrefix + "src"), existingModuleConfig(ws, sourcePath), @@ -922,23 +934,31 @@ type TypescriptSdk { # it. withNewDirectory swaps the whole directory, so writing only what we # generate would say everything else — the module's config and its source — # had been deleted. - let staged = ws.withNewDirectory("/" + sourcePath, existing.withDirectory(".", generated)) - - # Second pass, for the self call. A module's own API is not in the schema its - # bindings come from, and reading the schema that does carry it means loading - # the module — which needs the files the first pass just wrote. Both passes - # run every time, from the same inputs, so the result does not depend on what - # was on disk when the run started. - let selfTarget = selfClientTarget(staged, scope, name) - if (selfTarget.length == 0) { - staged + ws.withNewDirectory("/" + sourcePath, existing.withDirectory(".", generated)) + } + + """ + Rewrite a module's bindings with its own API folded in. + + The second of generation's two passes. A module's own API is not in the schema + its bindings come from, and reading the schema that does carry it means loading + the module — which needs the files the first pass just wrote. Both passes run + every time, from the same inputs, so the result does not depend on what was on + disk when the run started. + + Only the bindings are redone. The entrypoint comes from scanning the user's + source, which the self types do not change, and it is the expensive half. + """ + let withSelfBindings(ws: Workspace!, scope: String!, sourcePath: String!, name: String!, targets: [ClientTarget!]!, selfTargets: [ClientTarget!]!): Workspace! { + if (selfTargets.length == 0) { + # Nothing to fold in, so the first pass already wrote the right bindings. + ws } else { - # Only the bindings are redone. The entrypoint comes from scanning the - # user's source, which the self types do not change, and it is the - # expensive half. - staged.withNewDirectory( + ws.withNewDirectory( "/" + moduleSdkPath(sourcePath), - moduleSdkDirectory(generateModuleBindings(schemaJSON, name, targets + selfTarget)), + moduleSdkDirectory( + generateModuleBindings(ws.moduleSource("/" + scope).introspectionSchemaJSON.contents, name, targets), + ), ) } } @@ -954,16 +974,30 @@ type TypescriptSdk { could not load before a run loads with the run's changes applied. """ let selfClientTarget(ws: Workspace!, scope: String!, name: String!): [ClientTarget!]! { - let schema = ws.moduleSource("/" + scope).clientSchemaIntrospectionJSON.contents rescue { - err: Error => "" + let selfSource = ws.moduleSource("/" + scope).{{ kind, clientSchemaIntrospectionJSON.{{ contents }} }} rescue { + err: Error => null } - if (schema == "") { + if (selfSource == null) { [] } else { - # Never a dependency and never in the standalone package: a module does not - # depend on itself, and the engine already serves it into its own session. - [ClientTarget(name: name, schemaJSON: schema, kindJSON: "\"\"", isLocal: true, path: scope, ref: "", pin: "", isSelf: true)] + # A local module like any other from the package's point of view: the + # generated bootstrap serves it by resolving `scope` against the workspace, + # so a caller outside the module reaches it the same way it reaches a + # target that was added on purpose. + # + # Never a dependency, though — a module does not depend on itself, and the + # engine already serves it into its own session. + [ClientTarget( + name: name, + schemaJSON: selfSource.clientSchemaIntrospectionJSON.contents, + kindJSON: JSON.encode(selfSource.kind), + isLocal: true, + path: scope, + ref: "", + pin: "", + isSelf: true, + )] } } From 5e73e344070eefbef00b81903cad137c80ea45b8 Mon Sep 17 00:00:00 2001 From: Vasek - Tom C Date: Wed, 9 Sep 2026 18:44:58 +0200 Subject: [PATCH 5/5] docs: record that the two generated directories are meant to merge A module writes its targets twice, and now its own API lands in both, so every module has a clients/ directory whether or not it has targets of its own. That reads like duplication worth removing until you know clients are being unified and the two directories merge into one -- so write it down, rather than leave the next reader to re-litigate it. Signed-off-by: Tom Chauveau --- design/module-max.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/design/module-max.md b/design/module-max.md index 501608b..4135182 100644 --- a/design/module-max.md +++ b/design/module-max.md @@ -314,6 +314,21 @@ Two scopes with the same basename (`apps/web`, `services/web`) produce the same package name. That only collides if a project installs both, which is not a shape the layout produces on its own — worth knowing, not worth designing around. +#### Deferred: one directory instead of two + +A module scope writes its targets twice — `sdk/` for its own source, `clients/` +for callers outside it — and its own API now appears in both, because it is a +target of itself like any other. That means every module has a `clients/` +directory, including one with no targets of its own, holding just its own +bindings. + +That is an intermediate state, not the intended end. Once clients are unified +the two directories merge into `clients/` alone, and the split stops being +visible. Until then the duplication is the honest shape: the two sets are +reached differently — one through the bundle as `@dagger.io/dagger`, one as an +installable package with a serve bootstrap — so a single directory today would +have to be two things at once. + #### Deferred: moving the module bundle to `src/internal/` The natural companion — `src/internal/clients` also holding the SDK bundle and