Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .dagger/modules/runtimes/main.dang
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ different interpreter, resolving @dagger.io/dagger a different way.
type Runtimes {
let fixtures: String! = ".dagger/modules/runtimes/fixtures"

"""
Engine release these tests run against, matching the version the vendored
library and committed bundle are built for.
"""
let engineVersion: String! = "1.0.0-beta.10"

"""
A node module should generate and run.
"""
Expand Down Expand Up @@ -61,7 +67,12 @@ type Runtimes {
and its default was applied.
"""
invokesFunctionCheck(ws: Workspace!): Void @check {
let target = sdkSdk.target(ws.directory("/"), ".")
# sdk-sdk pins its own CLI (1.0.0-beta.9), and the harness runs the whole
# user path through it. Our committed bundle is built for the engine this
# repo targets, so driving it with an older CLI pairs a beta.10 library with
# a beta.9 engine — the version coupling this SDK owns now that it ships the
# library. Pin the harness to the same engine instead.
let target = sdkSdk(daggerCliVersion: engineVersion).target(ws.directory("/"), ".")
let run = target.run(["call", target.moduleName, "base-image-address"])

run.assertSuccess
Expand Down
6 changes: 6 additions & 0 deletions dagger.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ check.skip = ["*"]

[modules.sdk-sdk]
source = "github.com/dagger/sdk-sdk"
# The contract suite drives a real CLI through the whole user path, so the
# release it runs has to match the engine this SDK's committed bundle is built
# for. sdk-sdk's own default tracks the same version today; pinning it here
# states the requirement, so its default moving ahead of ours shows up as a
# failing check rather than a library quietly paired with a newer engine.
settings.daggerCliVersion = "1.0.0-beta.10"

[modules.typescript-sdk.as-sdk]
name = "typescript"
Expand Down
15 changes: 11 additions & 4 deletions design/client-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,17 @@ upstream's `Local` SDK-lib origin (`detectSDKLibOrigin`: `"@dagger.io/dagger" ==
"deno.json"])`, mounted at `/existing`. (Guard for a not-yet-existing client
dir → fall back to empty.)

The vendored `sdk/` directory itself survives regeneration for free: the fork
stages the client via `withDirectory(client.path, generated)`, which is an
**overlay** — files the user has under `<client-dir>/sdk/` that codegen
doesn't emit are preserved.
The vendored `sdk/` directory itself survives regeneration for free — though
not because `withDirectory` is an overlay, as this originally claimed. The
polyfill documents it as "add or replace", and the replace is real in the
changeset's *after* tree. What makes this safe is that a client changeset is
only ever applied to disk, and applying is additive. Confirmed by
regenerating a client directory holding a vendored `sdk/core.js` and an
unrelated `NOTES.md`: both survive, and `removedPaths` comes back empty.

The distinction matters if this is reused elsewhere. Module generation stages
its changeset into a workspace and reads it back, where the replace *does*
drop unstaged files — see `module-gen.md`.

That's it. No new engine primitive, no bundle shipped by this repo, no new
generator modes.
Expand Down
15 changes: 15 additions & 0 deletions design/module-gen.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,21 @@ staleness check, with a better message than a hand-written one:
with the committed bindings and watching it fail. An engine bump therefore
shows up as a failing check rather than silent drift.

**On `fork.withDirectory` (worth knowing before reusing it).** The polyfill
documents it as "add or replace", and the replace is real: whatever is not
staged is absent from the changeset's *after* tree. That only shows up when a
changeset is staged into a workspace and read back — `ws.withChanges`, which is
how the engine generates a local dependency — where it silently drops the
module's own config and source. Applying to disk is additive, so the same
changeset looks fine there.

Module generation therefore stages a diff over the module's existing tree
(§7 Phase 3). Client generation does not need to: a client changeset is only
ever applied, never staged and re-read. Verified rather than assumed — a client
directory carrying a vendored `sdk/` and an unrelated file keeps both across a
regeneration, and the changeset's `removedPaths` is empty, so the preservation
`client-bundle.md` relies on holds.

**VCS files are not written** (decided): no `.gitignore`, no `.gitattributes`.
The engine appends to both around codegen, but for a workspace module the
ignore list is reduced to `node_modules`/`.pnpm-store` anyway, which is the
Expand Down
15 changes: 15 additions & 0 deletions helpers/codegen/generator/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ package generator

import (
"fmt"
"regexp"
"strings"
"unicode"

"codegen/introspection"
"golang.org/x/mod/semver"
)

const nullableObjectSDKCutoverVersion = "v1.0.0-beta.10"

var betaVersion = regexp.MustCompile(`^(v\d+\.\d+\.\d+-beta\.\d+)`)

const (
QueryStructName = "Query"
QueryStructClientName = "Client"
Expand Down Expand Up @@ -258,3 +263,13 @@ func (c *CommonFunctions) formatType(r *introspection.TypeRef, scope string, inp
func (c *CommonFunctions) CheckVersionCompatibility(minVersion string) bool {
return semver.Compare(c.schemaVersion, minVersion) >= 0
}

func SupportsNullableObjects(schemaVersion string) bool {
if schemaVersion == "" || !semver.IsValid(schemaVersion) {
return true
}
if version := betaVersion.FindString(schemaVersion); version != "" {
schemaVersion = version
}
return semver.Compare(schemaVersion, nullableObjectSDKCutoverVersion) >= 0
}
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,9 @@ func (c *entrypointFuncCtx) renderFunctionExpr(fn *TypedefFunction) string {
if fn.IsUp {
parts = append(parts, ".withUp()")
}
if fn.IsAgent {
parts = append(parts, ".withAgent()")
}
return strings.Join(parts, "")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type TypedefFunction struct {
IsCheck bool `json:"isCheck"`
IsGenerator bool `json:"isGenerator"`
IsUp bool `json:"isUp"`
IsAgent bool `json:"isAgent"`
Location *TypedefLocation `json:"location,omitempty"`
ReturnType *TypedefType `json:"returnType,omitempty"`
Arguments []*TypedefArgument `json:"arguments"`
Expand Down
11 changes: 10 additions & 1 deletion helpers/codegen/generator/typescript/templates/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ func (funcs typescriptTemplateFuncs) FuncMap() template.FuncMap {
"IsSelfChainable": commonFunc.IsSelfChainable,
"IsListOfObject": commonFunc.IsListOfObject,
"IsListOfInterface": funcs.isListOfInterface,
"IsNullableObject": funcs.isNullableObject,
"IsListOfEnum": commonFunc.IsListOfEnum,
"GetArrayField": commonFunc.GetArrayField,
"ToLowerCase": commonFunc.ToLowerCase,
Expand Down Expand Up @@ -180,6 +181,10 @@ func (funcs typescriptTemplateFuncs) legacyTypeScriptSDKCompat() bool {
return semver.Compare(funcs.schemaVersion, legacyTypeScriptSDKCompatCutoverVersion) < 0
}

func (funcs typescriptTemplateFuncs) supportsNullableObjects() bool {
return generator.SupportsNullableObjects(funcs.schemaVersion)
}

// isInterface checks if the type is a GraphQL interface.
func (funcs typescriptTemplateFuncs) isInterface(t *introspection.Type) bool {
return t.Kind == introspection.TypeKindInterface
Expand Down Expand Up @@ -336,7 +341,11 @@ func (funcs typescriptTemplateFuncs) solve(field introspection.Field) bool {
if field.TypeRef == nil {
return false
}
return field.TypeRef.IsScalar() || field.TypeRef.IsList()
return field.TypeRef.IsScalar() || field.TypeRef.IsList() || funcs.isNullableObject(field.TypeRef)
}

func (funcs typescriptTemplateFuncs) isNullableObject(ref *introspection.TypeRef) bool {
return funcs.supportsNullableObjects() && ref != nil && ref.IsOptional() && (ref.IsObject() || ref.IsInterface())
}

// subtract subtract integer a with integer b.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,21 @@ The dot is an introspection.Field. */ -}}
{{- end }}
){{- /* Add subfields */ -}}
{{- if and .TypeRef.IsList (IsListOfObject .TypeRef) }}.select("{{- range $i, $v := . | GetArrayField }}{{if $i }} {{ end }}{{ $v.Name | ToLowerCase }}{{- end }}")
{{- else if IsNullableObject .TypeRef }}.select("id")
{{- end }}

{{ if not .TypeRef.IsVoid }}const response: Awaited<{{ if $convertID }}{{ . | FormatFieldOutputType }}{{ else }}{{ $promiseRetType }}{{ end }}> = {{ end }}await ctx.execute()
{{ if not .TypeRef.IsVoid }}const response: Awaited<{{ if IsNullableObject .TypeRef }}string | null{{ else if $convertID }}{{ . | FormatFieldOutputType }}{{ else }}{{ $promiseRetType }}{{ end }}> = {{ end }}await ctx.execute()

{{ if $convertID -}}
{{ if IsNullableObject .TypeRef -}}
if (response === null) {
return null
}
{{- if .TypeRef.IsInterface }}
return new _{{ $promiseRetType | FormatProtected | FormatName }}Client(ctx.copy().selectNode(response, "{{ $promiseRetType | FormatProtected }}"))
{{- else }}
return new {{ $promiseRetType | FormatProtected | FormatName }}(ctx.copy().selectNode(response, "{{ $promiseRetType | FormatProtected }}"))
{{- end }}
{{- else if $convertID -}}
{{- if IsInterface .ParentObject }}
return new _{{ $promiseRetType | FormatProtected | FormatName }}Client(ctx.copy().selectNode(response, "{{ $promiseRetType | FormatProtected }}"))
{{- else }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
{{- end }}

{{- /* Write return type */ -}}
{{- "" }}): Promise<{{ if .TypeRef.IsVoid }}void{{ else }}{{ . | FormatFieldReturnType }}{{ end }}> => { {{- with .Directives.SourceMap }} // {{ .Module }} ({{ .Filelink | ModuleRelPath }}) {{- end }}
{{- "" }}): Promise<{{ if .TypeRef.IsVoid }}void{{ else }}{{ . | FormatFieldReturnType }}{{ if IsNullableObject .TypeRef }} | null{{ end }}{{ end }}> => { {{- with .Directives.SourceMap }} // {{ .Module }} ({{ .Filelink | ModuleRelPath }}) {{- end }}
{{- /* Body is shared with the dep prototype augmentations. */ -}}
{{- template "method_solve_body" . }}
}
Expand Down
33 changes: 33 additions & 0 deletions helpers/codegen/generator/typescript/templates/src/object_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,39 @@ func TestObjectFieldDeprecated(t *testing.T) {
require.Equal(t, want, b.String())
}

func TestNullableObjectField(t *testing.T) {
object := objectInit(t, `{
"kind": "OBJECT",
"name": "GitRepository",
"description": "",
"fields": [{
"args": [],
"description": "",
"isDeprecated": false,
"name": "latestVersion",
"type": {"kind": "OBJECT", "name": "GitRef"}
}],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
}`)

tmpl := templateHelper(t)
var b bytes.Buffer
require.NoError(t, tmpl.ExecuteTemplate(&b, "object", object))
require.Contains(t, b.String(), "latestVersion = async (): Promise<GitRef | null>")
require.Contains(t, b.String(), `"latestVersion",`)
require.Contains(t, b.String(), `.select("id")`)
require.Contains(t, b.String(), "if (response === null)")

tmpl = templates.New("v1.0.0-beta.9", nil, "", generator.Config{})
b.Reset()
require.NoError(t, tmpl.ExecuteTemplate(&b, "object", object))
require.Contains(t, b.String(), "latestVersion = (): GitRef")
require.NotContains(t, b.String(), "response === null")
}

func TestInterfaceMethodOptionalArgDeprecated(t *testing.T) {
tmpl := templateHelper(t)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,18 @@ export class Host extends BaseClient {
/**
* Lookup the value of an environment variable. Null if the variable is not available.
*/
envVariable = (name: string): HostVariable => {

envVariable = async (name: string): Promise<HostVariable | null> => {
const ctx = this._ctx.select(
"envVariable",
{ name },
)
return new HostVariable(ctx)
{ name},
).select("id")

const response: Awaited<string | null> = await ctx.execute()

if (response === null) {
return null
}
return new HostVariable(ctx.copy().selectNode(response, "HostVariable"))
}

/**
Expand Down
4 changes: 1 addition & 3 deletions helpers/codegen/introspection/filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,11 @@ import "slices"
// installing a dependency.
var ExtendableTypes = []string{
"Query",
"Binding",
"Env",
}

// DependencyNames returns the unique list of module names that appear in
// the schema's sourceMap directives, excluding the built-in extendable types
// (Query, Binding, Env) whose fields are contributed by multiple modules.
// (Query) whose fields are contributed by multiple modules.
func (s *Schema) DependencyNames() []string {
seen := map[string]struct{}{}
var names []string
Expand Down
13 changes: 8 additions & 5 deletions library/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading