From 0138b60ebd7449c28fe977b8056ec70e768bbbbb Mon Sep 17 00:00:00 2001 From: Solomon Hykes Date: Fri, 18 Sep 2026 15:55:02 -0700 Subject: [PATCH 1/2] feat: support collection metadata and state --- docs/collections.md | 41 ++++++++ .../typescript/templates/collections_test.go | 24 +++++ .../typescript/templates/entrypoint_dang.go | 19 +++- .../templates/entrypoint_functions.go | 27 +++++- .../templates/entrypoint_typedef.go | 42 +++++---- library/src/api/client.gen.ts | 80 ++++++++++++++++ library/src/module/decorators.ts | 9 ++ library/src/module/entrypoint/load.ts | 11 +++ library/src/module/entrypoint/register.ts | 12 +++ .../introspector/dagger_module/decorator.ts | 12 +++ .../introspector/dagger_module/function.ts | 6 ++ .../introspector/dagger_module/module.ts | 16 +++- .../introspector/dagger_module/object.ts | 17 +++- .../introspector/dagger_module/objectBase.ts | 3 + .../introspector/dagger_module/property.ts | 19 +++- .../module/introspector/introspection_json.ts | 94 ++++++++++++++++++- .../test/collection_state.spec.ts | 42 +++++++++ .../test/introspection_json.spec.ts | 56 +++++++++++ .../test/testdata/collections/index.ts | 33 +++++++ .../src/module/introspector/typedef_json.ts | 4 + library/src/module/registry.ts | 4 + 21 files changed, 541 insertions(+), 30 deletions(-) create mode 100644 docs/collections.md create mode 100644 helpers/codegen/generator/typescript/templates/collections_test.go create mode 100644 library/src/module/introspector/test/collection_state.spec.ts create mode 100644 library/src/module/introspector/test/testdata/collections/index.ts diff --git a/docs/collections.md b/docs/collections.md new file mode 100644 index 0000000..2a35447 --- /dev/null +++ b/docs/collections.md @@ -0,0 +1,41 @@ +# Collections + +Requires the engine changes in [dagger/dagger#14221](https://github.com/dagger/dagger/pull/14221). + +A collection has stored keys and a function that returns one item for a key. +The engine supplies `keys`, `get`, `values`, `subset`, and `delta`. Other exposed +functions appear under `batch`. + +```typescript +import { collection, delta, func, get, keys, object, CollectionDelta } from "@dagger.io/dagger" + +@object() +class Item { + @func() name: string = "" +} + +@object() +@collection() +class Items { + @func() + @keys() + paths: string[] = [] + + @func() + @delta() + selection?: CollectionDelta + + @func() + @get() + item(key: string): Item { + return Object.assign(new Item(), { name: key }) + } +} +``` + +The generated TypeScript and Dang entrypoints both carry collection metadata. +Self-call clients use the projected collection schema. + +The engine fills the optional delta field before a module call. It compares the +current keys with the original keys. Copies preserve the internal base state. +A new object starts a new base. The internal state is not an exposed field. diff --git a/helpers/codegen/generator/typescript/templates/collections_test.go b/helpers/codegen/generator/typescript/templates/collections_test.go new file mode 100644 index 0000000..31ddf3b --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/collections_test.go @@ -0,0 +1,24 @@ +package templates + +import ( + "github.com/stretchr/testify/require" + "testing" +) + +func TestCollectionDangMetadata(t *testing.T) { + obj := &TypedefObject{Name: "Items", IsCollection: true, + Properties: map[string]*TypedefProperty{ + "names": {Name: "names", Alias: "paths", IsExposed: true, IsCollectionKeys: true, Type: &TypedefType{Kind: KindList, TypeDef: &TypedefType{Kind: KindString}}}, + "selection": {Name: "selection", IsExposed: true, IsCollectionDelta: true, Type: &TypedefType{Kind: KindObject, Name: "CollectionDelta"}}, + }, + Methods: map[string]*TypedefFunction{ + "item": {Name: "item", Alias: "lookup", IsCollectionGet: true, ReturnType: &TypedefType{Kind: KindObject, Name: "Item"}}, + }, + } + c := &dangFuncCtx{module: &TypedefModule{Name: "Main"}} + got := c.dangObjectEntry(obj) + require.Contains(t, got, ".withCollection") + require.Contains(t, got, `.withCollectionKeys("paths")`) + require.Contains(t, got, `.withCollectionDelta("selection")`) + require.Contains(t, got, `.withCollectionGet("lookup")`) +} diff --git a/helpers/codegen/generator/typescript/templates/entrypoint_dang.go b/helpers/codegen/generator/typescript/templates/entrypoint_dang.go index 46d7164..3220a66 100644 --- a/helpers/codegen/generator/typescript/templates/entrypoint_dang.go +++ b/helpers/codegen/generator/typescript/templates/entrypoint_dang.go @@ -219,13 +219,30 @@ func (c *dangFuncCtx) dangTypeEntries() []string { func (c *dangFuncCtx) dangObjectEntry(obj *TypedefObject) string { calls := []string{c.dangObjectDef(obj)} + if obj.IsCollection { + calls = append(calls, "withCollection") + } for _, name := range sortedFunctionKeys(obj.Methods) { - calls = append(calls, c.dangWrappedCall("withFunction", c.dangFunctionExpr(obj.Methods[name]))) + method := obj.Methods[name] + calls = append(calls, c.dangWrappedCall("withFunction", c.dangFunctionExpr(method))) + if method.IsCollectionGet { + member := method.Name + if method.Alias != "" { + member = method.Alias + } + calls = append(calls, fmt.Sprintf("withCollectionGet(%s)", dangString(member))) + } } for _, name := range sortedPropertyKeys(obj.Properties) { if prop := obj.Properties[name]; prop.IsExposed { calls = append(calls, c.dangFieldCall(prop)) + if prop.IsCollectionKeys { + calls = append(calls, fmt.Sprintf("withCollectionKeys(%s)", dangString(propFieldName(prop)))) + } + if prop.IsCollectionDelta { + calls = append(calls, fmt.Sprintf("withCollectionDelta(%s)", dangString(propFieldName(prop)))) + } } } // The engine identifies an entrypoint module's main object by its diff --git a/helpers/codegen/generator/typescript/templates/entrypoint_functions.go b/helpers/codegen/generator/typescript/templates/entrypoint_functions.go index 1c559d1..cb0ec40 100644 --- a/helpers/codegen/generator/typescript/templates/entrypoint_functions.go +++ b/helpers/codegen/generator/typescript/templates/entrypoint_functions.go @@ -3,7 +3,9 @@ package templates import ( "encoding/json" "fmt" + "maps" "path/filepath" + "slices" "sort" "strings" "text/template" @@ -292,7 +294,21 @@ func (c *entrypointFuncCtx) renderObjectDef(obj *TypedefObject) string { if sm := sourceMapExpr(obj.Location); sm != "" { opts["sourceMap"] = sm } - return fmt.Sprintf("dag.typeDef().withObject(%s%s)", jsString(obj.Name), optsLit(opts)) + result := fmt.Sprintf("dag.typeDef().withObject(%s%s)", jsString(obj.Name), optsLit(opts)) + if obj.IsCollection { + result += ".withCollection()" + } + for _, name := range slices.Sorted(maps.Keys(obj.Methods)) { + method := obj.Methods[name] + if method.IsCollectionGet { + member := method.Name + if method.Alias != "" { + member = method.Alias + } + result += fmt.Sprintf(".withCollectionGet(%s)", jsString(member)) + } + } + return result } func (c *entrypointFuncCtx) renderFieldCall(prop *TypedefProperty) string { @@ -306,7 +322,14 @@ func (c *entrypointFuncCtx) renderFieldCall(prop *TypedefProperty) string { if sm := sourceMapExpr(prop.Location); sm != "" { opts["sourceMap"] = sm } - return fmt.Sprintf(".withField(%s, %s%s)", jsString(propFieldName(prop)), c.renderTypeDef(prop.Type), optsLit(opts)) + result := fmt.Sprintf(".withField(%s, %s%s)", jsString(propFieldName(prop)), c.renderTypeDef(prop.Type), optsLit(opts)) + if prop.IsCollectionKeys { + result += fmt.Sprintf(".withCollectionKeys(%s)", jsString(propFieldName(prop))) + } + if prop.IsCollectionDelta { + result += fmt.Sprintf(".withCollectionDelta(%s)", jsString(propFieldName(prop))) + } + return result } func (c *entrypointFuncCtx) renderEnumDef(e *TypedefEnum) string { diff --git a/helpers/codegen/generator/typescript/templates/entrypoint_typedef.go b/helpers/codegen/generator/typescript/templates/entrypoint_typedef.go index 83ccbb2..5466292 100644 --- a/helpers/codegen/generator/typescript/templates/entrypoint_typedef.go +++ b/helpers/codegen/generator/typescript/templates/entrypoint_typedef.go @@ -17,6 +17,7 @@ type TypedefModule struct { } type TypedefObject struct { + IsCollection bool `json:"isCollection"` Name string `json:"name"` Kind string `json:"kind"` // "class" | "object" IsExported bool `json:"isExported"` @@ -35,18 +36,19 @@ type TypedefConstructor struct { } type TypedefFunction struct { - Name string `json:"name"` - Alias string `json:"alias,omitempty"` - Cache string `json:"cache,omitempty"` - Description string `json:"description"` - Deprecated string `json:"deprecated,omitempty"` - 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"` + IsCollectionGet bool `json:"isCollectionGet"` + Name string `json:"name"` + Alias string `json:"alias,omitempty"` + Cache string `json:"cache,omitempty"` + Description string `json:"description"` + Deprecated string `json:"deprecated,omitempty"` + 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"` } type TypedefArgument struct { @@ -65,13 +67,15 @@ type TypedefArgument struct { } type TypedefProperty struct { - Name string `json:"name"` - Alias string `json:"alias,omitempty"` - Description string `json:"description,omitempty"` - Deprecated string `json:"deprecated,omitempty"` - IsExposed bool `json:"isExposed"` - Type *TypedefType `json:"type,omitempty"` - Location *TypedefLocation `json:"location,omitempty"` + IsCollectionKeys bool `json:"isCollectionKeys"` + IsCollectionDelta bool `json:"isCollectionDelta"` + Name string `json:"name"` + Alias string `json:"alias,omitempty"` + Description string `json:"description,omitempty"` + Deprecated string `json:"deprecated,omitempty"` + IsExposed bool `json:"isExposed"` + Type *TypedefType `json:"type,omitempty"` + Location *TypedefLocation `json:"location,omitempty"` } type TypedefEnum struct { diff --git a/library/src/api/client.gen.ts b/library/src/api/client.gen.ts index 4986643..0a4331f 100644 --- a/library/src/api/client.gen.ts +++ b/library/src/api/client.gen.ts @@ -5640,6 +5640,56 @@ export class Cloud extends BaseClient { /** * An OCI-compatible container, also known as a Docker container. */ +export class CollectionDelta extends BaseClient { + private readonly _id?: ID = undefined + + /** + * Constructor is used for internal usage only, do not create object from it. + */ + constructor(ctx?: Context, _id?: ID) { + super(ctx) + + this._id = _id + } + + /** + * A unique identifier for this CollectionDelta. + */ + id = async (): Promise => { + if (this._id) { + return this._id + } + + const ctx = this._ctx.select("id") + + const response: Awaited = await ctx.execute() + + return response + } + + /** + * Current keys absent from the original collection, in current order. + */ + addedKeys = async (): Promise => { + const ctx = this._ctx.select("addedKeys") + + const response: Awaited = await ctx.execute() + + return response + } + + /** + * Original keys absent from the current collection, in original order. + */ + removedKeys = async (): Promise => { + const ctx = this._ctx.select("removedKeys") + + const response: Awaited = await ctx.execute() + + return response + } +} + export class Container extends BaseClient { private readonly _id?: ID | undefined = undefined private readonly _combinedOutput?: string | undefined = undefined @@ -18636,6 +18686,36 @@ export class TypeDef extends BaseClient { /** * Adds a function for constructing a new instance of an Object TypeDef, failing if the type is not an object. */ + withCollection = (): TypeDef => { + const ctx = this._ctx.select("withCollection") + return new TypeDef(ctx) + } + + /** + * Select the field that receives changes from the original collection. + */ + withCollectionDelta = (name: string): TypeDef => { + const ctx = this._ctx.select("withCollectionDelta", { name }) + return new TypeDef(ctx) + } + + /** + * Select the item lookup function for this collection. + */ + withCollectionGet = (name: string): TypeDef => { + const ctx = this._ctx.select("withCollectionGet", { name }) + return new TypeDef(ctx) + } + + /** + * Select the stored keys field for this collection. + */ + withCollectionKeys = (name: string): TypeDef => { + const ctx = this._ctx.select("withCollectionKeys", { name }) + return new TypeDef(ctx) + } + + withConstructor = (function_: Function_): TypeDef => { const ctx = this._ctx.select( diff --git a/library/src/module/decorators.ts b/library/src/module/decorators.ts index 99252d9..66d7c2e 100644 --- a/library/src/module/decorators.ts +++ b/library/src/module/decorators.ts @@ -10,6 +10,15 @@ import { registry } from "./registry.js" */ export const object = registry.object +/** Declare a collection object. */ +export const collection = registry.collection +/** Select the stored keys field. */ +export const keys = registry.keys +/** Select the item lookup method. */ +export const get = registry.get +/** Select the field that receives the collection delta. */ +export const delta = registry.delta + /** * The definition of @func decorator that should be on top of any * class' method that must be exposed to the Dagger API. diff --git a/library/src/module/entrypoint/load.ts b/library/src/module/entrypoint/load.ts index f18b7cc..95e65fc 100644 --- a/library/src/module/entrypoint/load.ts +++ b/library/src/module/entrypoint/load.ts @@ -20,6 +20,9 @@ import { import { TypeDef } from "../introspector/typedef.js" import { InvokeCtx } from "./context.js" +// Opaque engine state, excluded from the module schema. +const collectionBaseField = "__daggerCollectionBase" + /** * Import all given typescript files so that trigger their decorators * and register their class and functions inside the Registry. @@ -125,6 +128,10 @@ export async function loadParentState( const parentState: Args = {} for (const [key, value] of Object.entries(ctx.parentArgs)) { + if (key === collectionBaseField) { + parentState[key] = value + continue + } const property = object.properties[key] if (!property) { throw new Error(`could not find parent property ${key}`) @@ -287,6 +294,10 @@ export async function loadResult( const state: any = {} for (const [key, value] of Object.entries(result)) { + if (key === collectionBaseField) { + state[key] = value + continue + } const property = Object.values(object.properties).find( (p) => p.name === key, ) diff --git a/library/src/module/entrypoint/register.ts b/library/src/module/entrypoint/register.ts index e69942c..9d55768 100644 --- a/library/src/module/entrypoint/register.ts +++ b/library/src/module/entrypoint/register.ts @@ -55,10 +55,16 @@ export class Register { // Register the class Typedef object in Dagger let typeDef = dag.typeDef().withObject(object.name, objectOpts) + if (object.isCollection) { + typeDef = typeDef.withCollection() + } // Register all functions (methods) to this object Object.values(object.methods).forEach((method) => { typeDef = typeDef.withFunction(this.addFunction(method)) + if (method.isCollectionGet) { + typeDef = typeDef.withCollectionGet(method.alias ?? method.name) + } }) // Register all fields that belong to this object @@ -75,6 +81,12 @@ export class Register { addTypeDef(field.type!), fieldOpts, ) + if (field.isCollectionKeys) { + typeDef = typeDef.withCollectionKeys(field.alias ?? field.name) + } + if (field.isCollectionDelta) { + typeDef = typeDef.withCollectionDelta(field.alias ?? field.name) + } } }) diff --git a/library/src/module/introspector/dagger_module/decorator.ts b/library/src/module/introspector/dagger_module/decorator.ts index 54496f7..7211776 100644 --- a/library/src/module/introspector/dagger_module/decorator.ts +++ b/library/src/module/introspector/dagger_module/decorator.ts @@ -8,9 +8,17 @@ import { generate, up, agent, + collection, + keys, + get, + delta, } from "../../decorators.js" export type DaggerDecorators = + | "collection" + | "keys" + | "get" + | "delta" | "object" | "func" | "check" @@ -22,6 +30,10 @@ export type DaggerDecorators = | "field" export const OBJECT_DECORATOR = object.name as DaggerDecorators +export const COLLECTION_DECORATOR = collection.name as DaggerDecorators +export const KEYS_DECORATOR = keys.name as DaggerDecorators +export const GET_DECORATOR = get.name as DaggerDecorators +export const DELTA_DECORATOR = delta.name as DaggerDecorators export const FUNCTION_DECORATOR = func.name as DaggerDecorators export const CHECK_DECORATOR = check.name as DaggerDecorators export const GENERATOR_DECORATOR = generate.name as DaggerDecorators diff --git a/library/src/module/introspector/dagger_module/function.ts b/library/src/module/introspector/dagger_module/function.ts index b77f36b..1fd9f76 100644 --- a/library/src/module/introspector/dagger_module/function.ts +++ b/library/src/module/introspector/dagger_module/function.ts @@ -15,6 +15,7 @@ import { CHECK_DECORATOR, FUNCTION_DECORATOR, GENERATOR_DECORATOR, + GET_DECORATOR, UP_DECORATOR, } from "./decorator.js" import { Locatable } from "./locatable.js" @@ -35,6 +36,7 @@ export class DaggerFunction extends Locatable { public isGenerator: boolean = false public isUp: boolean = false public isAgent: boolean = false + public isCollectionGet: boolean = false private signature: ts.Signature private symbol: ts.Symbol @@ -48,6 +50,10 @@ export class DaggerFunction extends Locatable { this.symbol = this.ast.getSymbolOrThrow(node.name) this.signature = this.ast.getSignatureFromFunctionOrThrow(node) this.name = this.node.name.getText() + this.isCollectionGet = this.ast.isNodeDecoratedWith( + this.node, + GET_DECORATOR, + ) const { description, deprecated } = this.ast.getSymbolDoc(this.symbol) this.description = description this.deprecated = deprecated diff --git a/library/src/module/introspector/dagger_module/module.ts b/library/src/module/introspector/dagger_module/module.ts index 5df83fd..4b7e5ab 100644 --- a/library/src/module/introspector/dagger_module/module.ts +++ b/library/src/module/introspector/dagger_module/module.ts @@ -4,7 +4,11 @@ import ts from "typescript" import { TypeDefKind } from "../../../api/client.gen.js" import { IntrospectionError } from "../../../common/errors/index.js" import { AST, ResolvedNodeWithSymbol } from "../typescript_module/index.js" -import { ENUM_DECORATOR, OBJECT_DECORATOR } from "./decorator.js" +import { + COLLECTION_DECORATOR, + ENUM_DECORATOR, + OBJECT_DECORATOR, +} from "./decorator.js" import { DaggerEnum } from "./enum.js" import { DaggerEnumsBase } from "./enumBase.js" import { DaggerEnumClass } from "./enumClass.js" @@ -148,7 +152,10 @@ export class DaggerModule { continue } - if (this.ast.isNodeDecoratedWith(classRef.node, OBJECT_DECORATOR)) { + if ( + this.ast.isNodeDecoratedWith(classRef.node, OBJECT_DECORATOR) || + this.ast.isNodeDecoratedWith(classRef.node, COLLECTION_DECORATOR) + ) { const daggerObject = new DaggerObject(classRef.node, this.ast) this.objects[daggerObject.name] = daggerObject this.references[daggerObject.name] = { @@ -359,7 +366,10 @@ export class DaggerModule { } // or we return all classes decorated with @object - if (this.ast.isNodeDecoratedWith(classDecl.node, OBJECT_DECORATOR)) { + if ( + this.ast.isNodeDecoratedWith(classDecl.node, OBJECT_DECORATOR) || + this.ast.isNodeDecoratedWith(classDecl.node, COLLECTION_DECORATOR) + ) { allClasses.push(convertedDecl) } } diff --git a/library/src/module/introspector/dagger_module/object.ts b/library/src/module/introspector/dagger_module/object.ts index 5f7d8de..7a95058 100644 --- a/library/src/module/introspector/dagger_module/object.ts +++ b/library/src/module/introspector/dagger_module/object.ts @@ -3,7 +3,12 @@ import ts from "typescript" import { IntrospectionError } from "../../../common/errors/index.js" import { AST, Location } from "../typescript_module/index.js" import { DaggerConstructor } from "./constructor.js" -import { FUNCTION_DECORATOR, OBJECT_DECORATOR } from "./decorator.js" +import { + COLLECTION_DECORATOR, + FUNCTION_DECORATOR, + GET_DECORATOR, + OBJECT_DECORATOR, +} from "./decorator.js" import { DaggerFunction, DaggerFunctions } from "./function.js" import { Locatable } from "./locatable.js" import { DaggerObjectBase } from "./objectBase.js" @@ -24,6 +29,7 @@ import { References } from "./reference.js" * ``` */ export class DaggerObject extends Locatable implements DaggerObjectBase { + public isCollection: boolean public name: string public description: string public deprecated?: string @@ -52,7 +58,11 @@ export class DaggerObject extends Locatable implements DaggerObjectBase { } this.name = this.node.name.getText() - if (!this.ast.isNodeDecoratedWith(node, OBJECT_DECORATOR)) { + this.isCollection = this.ast.isNodeDecoratedWith(node, COLLECTION_DECORATOR) + if ( + !this.isCollection && + !this.ast.isNodeDecoratedWith(node, OBJECT_DECORATOR) + ) { throw new IntrospectionError( `class ${this.name} at ${AST.getNodePosition(node)} is used by the module but not exposed with a dagger decorator.`, ) @@ -91,7 +101,8 @@ export class DaggerObject extends Locatable implements DaggerObjectBase { if ( ts.isMethodDeclaration(member) && - this.ast.isNodeDecoratedWith(member, FUNCTION_DECORATOR) + (this.ast.isNodeDecoratedWith(member, FUNCTION_DECORATOR) || + this.ast.isNodeDecoratedWith(member, GET_DECORATOR)) ) { const daggerFunction = new DaggerFunction(member, this.ast) this.methods[daggerFunction.alias ?? daggerFunction.name] = diff --git a/library/src/module/introspector/dagger_module/objectBase.ts b/library/src/module/introspector/dagger_module/objectBase.ts index c9a71c4..cc626d7 100644 --- a/library/src/module/introspector/dagger_module/objectBase.ts +++ b/library/src/module/introspector/dagger_module/objectBase.ts @@ -11,6 +11,8 @@ export interface DaggerObjectPropertyBase extends Locatable { deprecated?: string alias?: string isExposed: boolean + isCollectionKeys?: boolean + isCollectionDelta?: boolean type?: TypeDef propagateReferences(references: References): void @@ -21,6 +23,7 @@ export type DaggerObjectPropertiesBase = { } export interface DaggerObjectBase extends Locatable { + isCollection?: boolean name: string description: string deprecated?: string diff --git a/library/src/module/introspector/dagger_module/property.ts b/library/src/module/introspector/dagger_module/property.ts index e5934f5..73508d4 100644 --- a/library/src/module/introspector/dagger_module/property.ts +++ b/library/src/module/introspector/dagger_module/property.ts @@ -8,7 +8,12 @@ import { isTypeDefResolved, resolveTypeDef, } from "../typescript_module/index.js" -import { FIELD_DECORATOR, FUNCTION_DECORATOR } from "./decorator.js" +import { + DELTA_DECORATOR, + FIELD_DECORATOR, + FUNCTION_DECORATOR, + KEYS_DECORATOR, +} from "./decorator.js" import { Locatable } from "./locatable.js" import { DaggerObjectPropertyBase } from "./objectBase.js" import { References } from "./reference.js" @@ -24,6 +29,8 @@ export class DaggerProperty public deprecated?: string public alias: string | undefined public isExposed: boolean + public isCollectionKeys: boolean + public isCollectionDelta: boolean private symbol: ts.Symbol private _typeRef?: string @@ -43,7 +50,17 @@ export class DaggerProperty this.symbol = this.ast.getSymbolOrThrow(this.node.name) this.name = this.node.name.getText() + this.isCollectionKeys = this.ast.isNodeDecoratedWith( + this.node, + KEYS_DECORATOR, + ) + this.isCollectionDelta = this.ast.isNodeDecoratedWith( + this.node, + DELTA_DECORATOR, + ) this.isExposed = + this.isCollectionKeys || + this.isCollectionDelta || this.ast.isNodeDecoratedWith(this.node, FUNCTION_DECORATOR) || this.ast.isNodeDecoratedWith(this.node, FIELD_DECORATOR) diff --git a/library/src/module/introspector/introspection_json.ts b/library/src/module/introspector/introspection_json.ts index 2c54cae..2154b38 100644 --- a/library/src/module/introspector/introspection_json.ts +++ b/library/src/module/introspector/introspection_json.ts @@ -120,7 +120,11 @@ export function serializeIntrospection( const types: IntrospectionType[] = [] for (const object of Object.values(module.objects)) { - types.push(introspectObject(object, moduleName, localTypeNames)) + if (object.isCollection) { + types.push(...introspectCollection(object, moduleName, localTypeNames)) + } else { + types.push(introspectObject(object, moduleName, localTypeNames)) + } } for (const iface of Object.values(module.interfaces)) { types.push(introspectInterface(iface, moduleName, localTypeNames)) @@ -200,6 +204,94 @@ function introspectObject( } } +function introspectCollection( + object: DaggerObjectBase, + moduleName: string, + local: Set, +): IntrospectionType[] { + const properties = Object.values(object.properties) + const methods = Object.values(object.methods) + const keys = + properties.find((p) => p.isCollectionKeys) ?? + properties.find( + (p) => p.isExposed && toLowerCamel(p.alias ?? p.name) === "keys", + ) + const get = + methods.find((m) => m.isCollectionGet) ?? + methods.find((m) => toLowerCamel(m.alias ?? m.name) === "get") + if (!keys || !get) { + throw new Error( + `collection "${object.name}" requires a stored keys field and a get method`, + ) + } + const lookup = introspectMethod(get, moduleName, local) + if (lookup.args.length !== 1) { + throw new Error( + `collection "${object.name}" get method requires one argument`, + ) + } + lookup.name = "get" + lookup.args[0].name = "key" + const name = introspectTypeName(object.name, moduleName) + const keyList = introspectProperty(keys, moduleName, local) + keyList.name = "keys" + const objectRef = (name: string): TypeRef => ({ + kind: TypeKind.NonNull, + ofType: { kind: TypeKind.Object, name }, + }) + const collection: IntrospectionType = { + kind: TypeKind.Object, + name, + description: trim(object.description), + interfaces: [], + fields: [ + keyList, + { + name: "list", + description: "", + args: [], + type: { + kind: TypeKind.NonNull, + ofType: { kind: TypeKind.List, ofType: lookup.type }, + }, + }, + lookup, + { + name: "subset", + description: "", + type: objectRef(name), + args: [{ name: "keys", description: "", type: keyList.type }], + }, + nodeIDField(name), + ], + } + const batchMethods = methods.filter((method) => method !== get) + if (batchMethods.length === 0) { + return [collection] + } + const batchName = name + "_Batch" + collection.fields!.push({ + name: "batch", + description: "", + args: [], + type: objectRef(batchName), + }) + return [ + collection, + { + kind: TypeKind.Object, + name: batchName, + interfaces: [], + fields: [ + ...batchMethods.map((method) => + introspectMethod(method, moduleName, local), + ), + nodeIDField(batchName), + ], + }, + ] +} + function introspectInterface( iface: DaggerInterface, moduleName: string, diff --git a/library/src/module/introspector/test/collection_state.spec.ts b/library/src/module/introspector/test/collection_state.spec.ts new file mode 100644 index 0000000..e0e957a --- /dev/null +++ b/library/src/module/introspector/test/collection_state.spec.ts @@ -0,0 +1,42 @@ +import { describe, it } from "mocha" +import assert from "node:assert/strict" + +import { loadParentState, loadResult } from "../../entrypoint/load.js" +import { Executor } from "../../executor.js" +import { scan } from "../index.js" +import { listFiles } from "../utils/files.js" + +describe("Collection state", () => { + it("preserves the base through copies without a delta value", async () => { + const files = await listFiles( + new URL("./testdata/collections", import.meta.url).pathname, + ) + const module = await scan(files, "collections") + const object = module.objects.Items + const executor = new Executor([], module) + const state = await loadParentState(executor, object as never, { + parentName: "Items", + fnName: "selected", + fnArgs: {}, + parentArgs: { + names: ["a", "b"], + prefix: "item:", + __daggerCollectionBase: "original", + }, + }) + const copy = { ...state, names: ["b", "c"] } + assert.deepEqual(await loadResult(copy, module, object), { + names: ["b", "c"], + prefix: "item:", + __daggerCollectionBase: "original", + }) + assert.deepEqual( + await loadResult({ names: ["c"], prefix: "item:" }, module, object), + { + names: ["c"], + prefix: "item:", + }, + ) + assert.equal(object.properties.__daggerCollectionBase, undefined) + }) +}) diff --git a/library/src/module/introspector/test/introspection_json.spec.ts b/library/src/module/introspector/test/introspection_json.spec.ts index 75afae7..66936be 100644 --- a/library/src/module/introspector/test/introspection_json.spec.ts +++ b/library/src/module/introspector/test/introspection_json.spec.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "url" import { scan } from "../index.js" import { serializeIntrospection } from "../introspection_json.js" +import { serializeModule } from "../typedef_json.js" import { listFiles } from "../utils/files.js" const __filename = fileURLToPath(import.meta.url) @@ -49,6 +50,61 @@ function fieldByName(t: IntrospectionType | undefined, name: string) { } describe("serializeIntrospection", function () { + it("projects collection members and retains the author registration", async function () { + const files = await listFiles(`${rootDirectory}/collections`) + const module = await scan(files, "collections") + const schema = serializeIntrospection(module as never).__schema + const collection = typeByName(schema, "CollectionsItems") + assert.deepEqual(collection?.fields?.map((f) => f.name).sort(), [ + "batch", + "get", + "id", + "keys", + "list", + "subset", + ]) + assert.equal(fieldByName(collection, "get")?.args[0]?.name, "key") + assert.equal( + fieldByName(collection, "batch")?.type.ofType?.name, + "CollectionsItems_Batch", + ) + assert.deepEqual( + typeByName(schema, "CollectionsItems_Batch") + ?.fields?.map((f) => f.name) + .sort(), + ["id", "selected"], + ) + assert.deepEqual( + fieldByName(collection, "keys")?.type, + fieldByName(collection, "subset")?.args[0]?.type, + ) + const registration = serializeModule(module) as { + objects: Record< + string, + { + isCollection: boolean + properties: Record< + string, + { isCollectionKeys: boolean; isCollectionDelta: boolean } + > + methods: Record + } + > + } + assert.equal(registration.objects.Items.isCollection, true) + assert.equal( + registration.objects.Items.properties.names.isCollectionKeys, + true, + ) + assert.equal( + registration.objects.Items.properties.selection.isCollectionDelta, + true, + ) + assert.equal( + registration.objects.Items.methods.lookup.isCollectionGet, + true, + ) + }) it("emits the main object, its methods and a Node id field", async function () { this.timeout(60000) const schema = await introspect("helloWorld") diff --git a/library/src/module/introspector/test/testdata/collections/index.ts b/library/src/module/introspector/test/testdata/collections/index.ts new file mode 100644 index 0000000..773c677 --- /dev/null +++ b/library/src/module/introspector/test/testdata/collections/index.ts @@ -0,0 +1,33 @@ +import { collection, delta, field, func, get, keys, object, CollectionDelta } from "../../../../../index.js" + +@object() +export class Collections { + @func() + items(): Items { return new Items() } +} + +@collection() +export class Items { + @keys() + names: string[] = ["b", "a"] + + @delta() + selection?: CollectionDelta + + @field() + prefix: string = "item:" + + @get() + lookup(name: string): Item { return new Item(this.prefix + name) } + + @func() + selected(): string[] { return this.names } +} + +@object() +export class Item { + @field() + name: string + + constructor(name: string) { this.name = name } +} diff --git a/library/src/module/introspector/typedef_json.ts b/library/src/module/introspector/typedef_json.ts index 8eca05a..20800e7 100644 --- a/library/src/module/introspector/typedef_json.ts +++ b/library/src/module/introspector/typedef_json.ts @@ -43,6 +43,7 @@ function serializeObject(obj: DaggerObjectBase) { return { name: obj.name, kind: obj.kind(), + isCollection: obj.isCollection === true, isExported: isExported !== false, isDefaultExport: isDefaultExport === true, description: obj.description, @@ -73,6 +74,7 @@ function serializeFunction(fn: DaggerFunction | DaggerInterfaceFunction) { isGenerator: f.isGenerator === true, isUp: f.isUp === true, isAgent: f.isAgent === true, + isCollectionGet: f.isCollectionGet === true, location: f.getLocation(), returnType: f.returnType ? serializeType(f.returnType) : undefined, arguments: Object.values(f.arguments).map(serializeArgument), @@ -103,6 +105,8 @@ function serializeProperty(prop: DaggerObjectPropertyBase) { description: prop.description, deprecated: prop.deprecated, isExposed: prop.isExposed === true, + isCollectionKeys: prop.isCollectionKeys === true, + isCollectionDelta: prop.isCollectionDelta === true, type: prop.type ? serializeType(prop.type) : undefined, location: prop.getLocation(), } diff --git a/library/src/module/registry.ts b/library/src/module/registry.ts index e13a5ac..aa91991 100644 --- a/library/src/module/registry.ts +++ b/library/src/module/registry.ts @@ -79,6 +79,10 @@ export type FunctionOptions = { * RegistryClass. */ export class Registry { + collection = (): ((constructor: T) => T) => this.object() + keys = (): PropertyDecorator => () => {} + delta = (): PropertyDecorator => () => {} + get = (): MethodDecorator => () => {} /** * The definition of the @object decorator that should be on top of any * class module that must be exposed to the Dagger API. From 60d78fab3fbdc604712cf0ce9a7967b0497e840c Mon Sep 17 00:00:00 2001 From: Solomon Hykes Date: Fri, 18 Sep 2026 16:29:58 -0700 Subject: [PATCH 2/2] test: order collection fixture types before their use --- docs/collections.md | 2 +- .../test/testdata/collections/index.ts | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/collections.md b/docs/collections.md index 2a35447..6d46e94 100644 --- a/docs/collections.md +++ b/docs/collections.md @@ -3,7 +3,7 @@ Requires the engine changes in [dagger/dagger#14221](https://github.com/dagger/dagger/pull/14221). A collection has stored keys and a function that returns one item for a key. -The engine supplies `keys`, `get`, `values`, `subset`, and `delta`. Other exposed +The engine supplies `keys`, `get`, `list`, and `subset`. Other exposed functions appear under `batch`. ```typescript diff --git a/library/src/module/introspector/test/testdata/collections/index.ts b/library/src/module/introspector/test/testdata/collections/index.ts index 773c677..8b3eedc 100644 --- a/library/src/module/introspector/test/testdata/collections/index.ts +++ b/library/src/module/introspector/test/testdata/collections/index.ts @@ -1,9 +1,11 @@ import { collection, delta, field, func, get, keys, object, CollectionDelta } from "../../../../../index.js" @object() -export class Collections { - @func() - items(): Items { return new Items() } +export class Item { + @field() + name: string + + constructor(name: string) { this.name = name } } @collection() @@ -24,10 +26,10 @@ export class Items { selected(): string[] { return this.names } } -@object() -export class Item { - @field() - name: string - constructor(name: string) { this.name = name } +@object() +export class Collections { + @func() + items(): Items { return new Items() } } +