From 340775a59390b2e57e0f313ed9245e9e788ce537 Mon Sep 17 00:00:00 2001 From: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> Date: Tue, 1 Jul 2025 17:40:39 -0600 Subject: [PATCH] Feature/more dynamic unions (#2972) * with validators * more dynamic unions * fixes from v31 * better constructor for dynamic unions * version bump * fix build --- container-runtime/package-lock.json | 2 +- image-recipe/build.sh | 4 + sdk/base/lib/actions/index.ts | 2 +- .../lib/actions/input/builder/inputSpec.ts | 69 +- sdk/base/lib/actions/input/builder/list.ts | 51 +- sdk/base/lib/actions/input/builder/value.ts | 702 +++++++++++------- .../lib/actions/input/builder/variants.ts | 122 ++- .../lib/actions/input/inputSpecConstants.ts | 71 +- sdk/base/lib/actions/setupActions.ts | 68 +- sdk/base/lib/test/inputSpecTypes.test.ts | 8 +- sdk/package/lib/StartSdk.ts | 11 +- sdk/package/lib/test/inputSpecBuilder.test.ts | 234 +++--- sdk/package/lib/test/output.test.ts | 24 +- sdk/package/lib/version/VersionGraph.ts | 16 +- sdk/package/lib/version/VersionInfo.ts | 8 +- sdk/package/package-lock.json | 4 +- sdk/package/package.json | 2 +- sdk/package/scripts/oldSpecToBuilder.ts | 14 +- .../system/routes/acme/acme.component.ts | 12 +- .../ui/src/app/services/api/api.fixures.ts | 48 +- .../ui/src/app/utils/configBuilderToSpec.ts | 2 +- 21 files changed, 863 insertions(+), 611 deletions(-) diff --git a/container-runtime/package-lock.json b/container-runtime/package-lock.json index 6cc8af474..4bf89a417 100644 --- a/container-runtime/package-lock.json +++ b/container-runtime/package-lock.json @@ -38,7 +38,7 @@ }, "../sdk/dist": { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.30", + "version": "0.4.0-beta.32", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", diff --git a/image-recipe/build.sh b/image-recipe/build.sh index dbf5805ee..3d97f7e5c 100755 --- a/image-recipe/build.sh +++ b/image-recipe/build.sh @@ -150,6 +150,10 @@ cat > config/archives/backports.pref <<- EOF Package: linux-image-* Pin: release n=${IB_SUITE}-backports Pin-Priority: 500 + +Package: linux-base +Pin: release n=${IB_SUITE}-backports +Pin-Priority: 500 EOF if [ "${IB_TARGET_PLATFORM}" = "rockchip64" ]; then diff --git a/sdk/base/lib/actions/index.ts b/sdk/base/lib/actions/index.ts index 312862012..e3204917b 100644 --- a/sdk/base/lib/actions/index.ts +++ b/sdk/base/lib/actions/index.ts @@ -46,7 +46,7 @@ export const runAction = async < } } type GetActionInputType> = - A extends Action ? ExtractInputSpecType : never + A extends Action ? I : never type TaskBase = { reason?: string diff --git a/sdk/base/lib/actions/input/builder/inputSpec.ts b/sdk/base/lib/actions/input/builder/inputSpec.ts index cd3a6e5d8..8df1ebe50 100644 --- a/sdk/base/lib/actions/input/builder/inputSpec.ts +++ b/sdk/base/lib/actions/input/builder/inputSpec.ts @@ -13,13 +13,17 @@ export type LazyBuild = ( ) => Promise | ExpectedOut // prettier-ignore -export type ExtractInputSpecType | InputSpec>> = - A extends InputSpec ? B : - A +export type ExtractInputSpecType, any>> = + A extends InputSpec ? B : + never -export type ExtractPartialInputSpecType< - A extends Record | InputSpec>, -> = A extends InputSpec ? DeepPartial : DeepPartial +export type ExtractInputSpecStaticValidatedAs< + A extends InputSpec>, +> = A extends InputSpec ? B : never + +// export type ExtractPartialInputSpecType< +// A extends Record | InputSpec>, +// > = A extends InputSpec ? DeepPartial : DeepPartial export type InputSpecOf> = { [K in keyof A]: Value @@ -82,35 +86,54 @@ export const addNodesSpec = InputSpec.of({ hostname: hostname, port: port }); ``` */ -export class InputSpec> { +export class InputSpec< + Type extends StaticValidatedAs, + StaticValidatedAs extends Record = Type, +> { private constructor( private readonly spec: { [K in keyof Type]: Value }, - public validator: Parser, + public readonly validator: Parser, ) {} public _TYPE: Type = null as any as Type public _PARTIAL: DeepPartial = null as any as DeepPartial - async build(options: LazyBuildOptions) { + async build(options: LazyBuildOptions): Promise<{ + spec: { + [K in keyof Type]: ValueSpec + } + validator: Parser + }> { const answer = {} as { [K in keyof Type]: ValueSpec } - for (const k in this.spec) { - answer[k] = await this.spec[k].build(options as any) + const validator = {} as { + [K in keyof Type]: Parser + } + for (const k in this.spec) { + const built = await this.spec[k].build(options as any) + answer[k] = built.spec + validator[k] = built.validator + } + return { + spec: answer, + validator: object(validator) as any, } - return answer } - static of>>(spec: Spec) { - const validatorObj = {} as { - [K in keyof Spec]: Parser - } - for (const key in spec) { - validatorObj[key] = spec[key].validator - } - const validator = object(validatorObj) - return new InputSpec<{ - [K in keyof Spec]: Spec[K] extends Value ? T : never - }>(spec, validator as any) + static of>>(spec: Spec) { + const validator = object( + Object.fromEntries( + Object.entries(spec).map(([k, v]) => [k, v.validator]), + ), + ) + return new InputSpec< + { + [K in keyof Spec]: Spec[K] extends Value ? T : never + }, + { + [K in keyof Spec]: Spec[K] extends Value ? T : never + } + >(spec, validator as any) } } diff --git a/sdk/base/lib/actions/input/builder/list.ts b/sdk/base/lib/actions/input/builder/list.ts index 0639c0b3f..02c16ae7c 100644 --- a/sdk/base/lib/actions/input/builder/list.ts +++ b/sdk/base/lib/actions/input/builder/list.ts @@ -9,11 +9,15 @@ import { } from "../inputSpecTypes" import { Parser, arrayOf, string } from "ts-matches" -export class List { +export class List { private constructor( - public build: LazyBuild, - public validator: Parser, + public build: LazyBuild<{ + spec: ValueSpecList + validator: Parser + }>, + public readonly validator: Parser, ) {} + readonly _TYPE: Type = null as any static text( a: { @@ -58,6 +62,7 @@ export class List { generate?: null | RandomString }, ) { + const validator = arrayOf(string) return new List(() => { const spec = { type: "text" as const, @@ -81,8 +86,8 @@ export class List { ...a, spec, } - return built - }, arrayOf(string)) + return { spec: built, validator } + }, validator) } static dynamicText( @@ -105,6 +110,7 @@ export class List { } }>, ) { + const validator = arrayOf(string) return new List(async (options) => { const { spec: aSpec, ...a } = await getA(options) const spec = { @@ -129,11 +135,15 @@ export class List { ...a, spec, } - return built - }, arrayOf(string)) + + return { spec: built, validator } + }, validator) } - static obj>( + static obj< + Type extends StaticValidatedAs, + StaticValidatedAs extends Record, + >( a: { name: string description?: string | null @@ -143,20 +153,20 @@ export class List { maxLength?: number | null }, aSpec: { - spec: InputSpec + spec: InputSpec displayAs?: null | string uniqueBy?: null | UniqueBy }, ) { - return new List(async (options) => { + return new List(async (options) => { const { spec: previousSpecSpec, ...restSpec } = aSpec - const specSpec = await previousSpecSpec.build(options) + const built = await previousSpecSpec.build(options) const spec = { type: "object" as const, displayAs: null, uniqueBy: null, ...restSpec, - spec: specSpec, + spec: built.spec, } const value = { spec, @@ -164,13 +174,16 @@ export class List { ...a, } return { - description: null, - warning: null, - minLength: null, - maxLength: null, - type: "list" as const, - disabled: false, - ...value, + spec: { + description: null, + warning: null, + minLength: null, + maxLength: null, + type: "list" as const, + disabled: false, + ...value, + }, + validator: arrayOf(built.validator), } }, arrayOf(aSpec.spec.validator)) } diff --git a/sdk/base/lib/actions/input/builder/value.ts b/sdk/base/lib/actions/input/builder/value.ts index 5d101dfa8..5bfaadf0b 100644 --- a/sdk/base/lib/actions/input/builder/value.ts +++ b/sdk/base/lib/actions/input/builder/value.ts @@ -1,6 +1,6 @@ -import { InputSpec, LazyBuild } from "./inputSpec" +import { ExtractInputSpecType, InputSpec, LazyBuild } from "./inputSpec" import { List } from "./list" -import { Variants } from "./variants" +import { UnionRes, UnionResStaticValidatedAs, Variants } from "./variants" import { FilePath, Pattern, @@ -34,19 +34,21 @@ type AsRequired = Required extends true const testForAsRequiredParser = once( () => object({ required: literal(true) }).test, ) -function asRequiredParser< - Type, - Input, - Return extends Parser | Parser, ->(parser: Parser, input: Input): Return { +function asRequiredParser( + parser: Parser, + input: Input, +): Parser> { if (testForAsRequiredParser()(input)) return parser as any return parser.nullable() as any } -export class Value { +export class Value { protected constructor( - public build: LazyBuild, - public validator: Parser, + public build: LazyBuild<{ + spec: ValueSpec + validator: Parser + }>, + public readonly validator: Parser, ) {} public _TYPE: Type = null as any as Type public _PARTIAL: DeepPartial = null as any as DeepPartial @@ -79,16 +81,20 @@ export class Value { */ immutable?: boolean }) { + const validator = boolean return new Value( async () => ({ - description: null, - warning: null, - type: "toggle" as const, - disabled: false, - immutable: a.immutable ?? false, - ...a, + spec: { + description: null, + warning: null, + type: "toggle" as const, + disabled: false, + immutable: a.immutable ?? false, + ...a, + }, + validator, }), - boolean, + validator, ) } static dynamicToggle( @@ -100,16 +106,20 @@ export class Value { disabled?: false | string }>, ) { + const validator = boolean return new Value( async (options) => ({ - description: null, - warning: null, - type: "toggle" as const, - disabled: false, - immutable: false, - ...(await a(options)), + spec: { + description: null, + warning: null, + type: "toggle" as const, + disabled: false, + immutable: false, + ...(await a(options)), + }, + validator, }), - boolean, + validator, ) } /** @@ -187,32 +197,36 @@ export class Value { */ generate?: RandomString | null }) { + const validator = asRequiredParser(string, a) return new Value>( async () => ({ - type: "text" as const, - description: null, - warning: null, - masked: false, - placeholder: null, - minLength: null, - maxLength: null, - patterns: [], - inputmode: "text", - disabled: false, - immutable: a.immutable ?? false, - generate: a.generate ?? null, - ...a, + spec: { + type: "text" as const, + description: null, + warning: null, + masked: false, + placeholder: null, + minLength: null, + maxLength: null, + patterns: [], + inputmode: "text", + disabled: false, + immutable: a.immutable ?? false, + generate: a.generate ?? null, + ...a, + }, + validator, }), - asRequiredParser(string, a), + validator, ) } - static dynamicText( + static dynamicText( getA: LazyBuild<{ name: string description?: string | null warning?: string | null default: DefaultString | null - required: boolean + required: Required masked?: boolean placeholder?: string | null minLength?: number | null @@ -223,24 +237,30 @@ export class Value { generate?: null | RandomString }>, ) { - return new Value(async (options) => { - const a = await getA(options) - return { - type: "text" as const, - description: null, - warning: null, - masked: false, - placeholder: null, - minLength: null, - maxLength: null, - patterns: [], - inputmode: "text", - disabled: false, - immutable: false, - generate: a.generate ?? null, - ...a, - } - }, string.nullable()) + return new Value, string | null>( + async (options) => { + const a = await getA(options) + return { + spec: { + type: "text" as const, + description: null, + warning: null, + masked: false, + placeholder: null, + minLength: null, + maxLength: null, + patterns: [], + inputmode: "text", + disabled: false, + immutable: false, + generate: a.generate ?? null, + ...a, + }, + validator: asRequiredParser(string, a), + } + }, + string.nullable(), + ) } /** * @description Displays a large textarea field for long form entry. @@ -278,40 +298,9 @@ export class Value { */ immutable?: boolean }) { - return new Value>( - async () => { - const built: ValueSpecTextarea = { - description: null, - warning: null, - minLength: null, - maxLength: null, - placeholder: null, - type: "textarea" as const, - disabled: false, - immutable: a.immutable ?? false, - ...a, - } - return built - }, - asRequiredParser(string, a), - ) - } - static dynamicTextarea( - getA: LazyBuild<{ - name: string - description?: string | null - warning?: string | null - default: string | null - required: boolean - minLength?: number | null - maxLength?: number | null - placeholder?: string | null - disabled?: false | string - }>, - ) { - return new Value(async (options) => { - const a = await getA(options) - return { + const validator = asRequiredParser(string, a) + return new Value>(async () => { + const built: ValueSpecTextarea = { description: null, warning: null, minLength: null, @@ -319,10 +308,45 @@ export class Value { placeholder: null, type: "textarea" as const, disabled: false, - immutable: false, + immutable: a.immutable ?? false, ...a, } - }, string.nullable()) + return { spec: built, validator } + }, validator) + } + static dynamicTextarea( + getA: LazyBuild<{ + name: string + description?: string | null + warning?: string | null + default: string | null + required: Required + minLength?: number | null + maxLength?: number | null + placeholder?: string | null + disabled?: false | string + }>, + ) { + return new Value, string | null>( + async (options) => { + const a = await getA(options) + return { + spec: { + description: null, + warning: null, + minLength: null, + maxLength: null, + placeholder: null, + type: "textarea" as const, + disabled: false, + immutable: false, + ...a, + }, + validator: asRequiredParser(string, a), + } + }, + string.nullable(), + ) } /** * @description Displays a number input field @@ -382,30 +406,34 @@ export class Value { */ immutable?: boolean }) { + const validator = asRequiredParser(number, a) return new Value>( () => ({ - type: "number" as const, - description: null, - warning: null, - min: null, - max: null, - step: null, - units: null, - placeholder: null, - disabled: false, - immutable: a.immutable ?? false, - ...a, + spec: { + type: "number" as const, + description: null, + warning: null, + min: null, + max: null, + step: null, + units: null, + placeholder: null, + disabled: false, + immutable: a.immutable ?? false, + ...a, + }, + validator, }), - asRequiredParser(number, a), + validator, ) } - static dynamicNumber( + static dynamicNumber( getA: LazyBuild<{ name: string description?: string | null warning?: string | null default: number | null - required: boolean + required: Required min?: number | null max?: number | null step?: number | null @@ -415,22 +443,28 @@ export class Value { disabled?: false | string }>, ) { - return new Value(async (options) => { - const a = await getA(options) - return { - type: "number" as const, - description: null, - warning: null, - min: null, - max: null, - step: null, - units: null, - placeholder: null, - disabled: false, - immutable: false, - ...a, - } - }, number.nullable()) + return new Value, number | null>( + async (options) => { + const a = await getA(options) + return { + spec: { + type: "number" as const, + description: null, + warning: null, + min: null, + max: null, + step: null, + units: null, + placeholder: null, + disabled: false as const, + immutable: false, + ...a, + }, + validator: asRequiredParser(number, a), + } + }, + number.nullable(), + ) } /** * @description Displays a browser-native color selector. @@ -468,40 +502,50 @@ export class Value { */ immutable?: boolean }) { + const validator = asRequiredParser(string, a) return new Value>( () => ({ - type: "color" as const, - description: null, - warning: null, - disabled: false, - immutable: a.immutable ?? false, - ...a, + spec: { + type: "color" as const, + description: null, + warning: null, + disabled: false, + immutable: a.immutable ?? false, + ...a, + }, + validator, }), - asRequiredParser(string, a), + validator, ) } - static dynamicColor( + static dynamicColor( getA: LazyBuild<{ name: string description?: string | null warning?: string | null default: string | null - required: boolean + required: Required disabled?: false | string }>, ) { - return new Value(async (options) => { - const a = await getA(options) - return { - type: "color" as const, - description: null, - warning: null, - disabled: false, - immutable: false, - ...a, - } - }, string.nullable()) + return new Value, string | null>( + async (options) => { + const a = await getA(options) + return { + spec: { + type: "color" as const, + description: null, + warning: null, + disabled: false, + immutable: false, + ...a, + }, + validator: asRequiredParser(string, a), + } + }, + string.nullable(), + ) } /** * @description Displays a browser-native date/time selector. @@ -549,49 +593,59 @@ export class Value { */ immutable?: boolean }) { + const validator = asRequiredParser(string, a) return new Value>( () => ({ - type: "datetime" as const, - description: null, - warning: null, - inputmode: "datetime-local", - min: null, - max: null, - step: null, - disabled: false, - immutable: a.immutable ?? false, - ...a, + spec: { + type: "datetime" as const, + description: null, + warning: null, + inputmode: "datetime-local", + min: null, + max: null, + step: null, + disabled: false, + immutable: a.immutable ?? false, + ...a, + }, + validator, }), - asRequiredParser(string, a), + validator, ) } - static dynamicDatetime( + static dynamicDatetime( getA: LazyBuild<{ name: string description?: string | null warning?: string | null default: string | null - required: boolean + required: Required inputmode?: ValueSpecDatetime["inputmode"] min?: string | null max?: string | null disabled?: false | string }>, ) { - return new Value(async (options) => { - const a = await getA(options) - return { - type: "datetime" as const, - description: null, - warning: null, - inputmode: "datetime-local", - min: null, - max: null, - disabled: false, - immutable: false, - ...a, - } - }, string.nullable()) + return new Value, string | null>( + async (options) => { + const a = await getA(options) + return { + spec: { + type: "datetime" as const, + description: null, + warning: null, + inputmode: "datetime-local", + min: null, + max: null, + disabled: false, + immutable: false, + ...a, + }, + validator: asRequiredParser(string, a), + } + }, + string.nullable(), + ) } /** * @description Displays a select modal with radio buttons, allowing for a single selection. @@ -644,39 +698,50 @@ export class Value { */ immutable?: boolean }) { + const validator = anyOf( + ...Object.keys(a.values).map((x: keyof Values & string) => literal(x)), + ) return new Value( () => ({ - description: null, - warning: null, - type: "select" as const, - disabled: false, - immutable: a.immutable ?? false, - ...a, + spec: { + description: null, + warning: null, + type: "select" as const, + disabled: false, + immutable: a.immutable ?? false, + ...a, + }, + validator, }), - anyOf( - ...Object.keys(a.values).map((x: keyof Values & string) => literal(x)), - ), + validator, ) } - static dynamicSelect( + static dynamicSelect>( getA: LazyBuild<{ name: string description?: string | null warning?: string | null default: string - values: Record + values: Values disabled?: false | string | string[] }>, ) { - return new Value(async (options) => { + return new Value(async (options) => { const a = await getA(options) return { - description: null, - warning: null, - type: "select" as const, - disabled: false, - immutable: false, - ...a, + spec: { + description: null, + warning: null, + type: "select" as const, + disabled: false, + immutable: false, + ...a, + }, + validator: anyOf( + ...Object.keys(a.values).map((x: keyof Values & string) => + literal(x), + ), + ), } }, string) } @@ -732,45 +797,56 @@ export class Value { */ immutable?: boolean }) { - return new Value<(keyof Values)[]>( + const validator = arrayOf( + literals(...(Object.keys(a.values) as any as [keyof Values & string])), + ) + return new Value<(keyof Values & string)[]>( () => ({ - type: "multiselect" as const, - minLength: null, - maxLength: null, - warning: null, - description: null, - disabled: false, - immutable: a.immutable ?? false, - ...a, + spec: { + type: "multiselect" as const, + minLength: null, + maxLength: null, + warning: null, + description: null, + disabled: false, + immutable: a.immutable ?? false, + ...a, + }, + validator, }), - arrayOf( - literals(...(Object.keys(a.values) as any as [keyof Values & string])), - ), + validator, ) } - static dynamicMultiselect( + static dynamicMultiselect>( getA: LazyBuild<{ name: string description?: string | null warning?: string | null default: string[] - values: Record + values: Values minLength?: number | null maxLength?: number | null disabled?: false | string | string[] }>, ) { - return new Value(async (options) => { + return new Value<(keyof Values & string)[], string[]>(async (options) => { const a = await getA(options) return { - type: "multiselect" as const, - minLength: null, - maxLength: null, - warning: null, - description: null, - disabled: false, - immutable: false, - ...a, + spec: { + type: "multiselect" as const, + minLength: null, + maxLength: null, + warning: null, + description: null, + disabled: false, + immutable: false, + ...a, + }, + validator: arrayOf( + literals( + ...(Object.keys(a.values) as any as [keyof Values & string]), + ), + ), } }, arrayOf(string)) } @@ -791,21 +867,27 @@ export class Value { ), * ``` */ - static object>( + static object< + Type extends StaticValidatedAs, + StaticValidatedAs extends Record, + >( a: { name: string description?: string | null }, - spec: InputSpec, + spec: InputSpec, ) { - return new Value(async (options) => { + return new Value(async (options) => { const built = await spec.build(options as any) return { - type: "object" as const, - description: null, - warning: null, - ...a, - spec: built, + spec: { + type: "object" as const, + description: null, + warning: null, + ...a, + spec: built.spec, + }, + validator: built.validator, } }, spec.validator) } @@ -886,38 +968,42 @@ export class Value { spec: InputSpec } }, - >( - a: { - name: string - description?: string | null - /** Presents a warning prompt before permitting the value to change. */ - warning?: string | null - /** - * @description Provide a default value from the list of variants. - * @type { string } - * @example default: 'variant1' - */ - default: keyof VariantValues & string - /** - * @description Once set, the value can never be changed. - * @default false - */ - immutable?: boolean - }, - aVariants: Variants, - ) { - return new Value( - async (options) => ({ - type: "union" as const, - description: null, - warning: null, - disabled: false, - ...a, - variants: await aVariants.build(options as any), - immutable: a.immutable ?? false, - }), - aVariants.validator, - ) + >(a: { + name: string + description?: string | null + /** Presents a warning prompt before permitting the value to change. */ + warning?: string | null + variants: Variants + /** + * @description Provide a default value from the list of variants. + * @type { string } + * @example default: 'variant1' + */ + default: keyof VariantValues & string + /** + * @description Once set, the value can never be changed. + * @default false + */ + immutable?: boolean + }) { + return new Value< + typeof a.variants._TYPE, + typeof a.variants.validator._TYPE + >(async (options) => { + const built = await a.variants.build(options as any) + return { + spec: { + type: "union" as const, + description: null, + warning: null, + disabled: false, + ...a, + variants: built.spec, + immutable: a.immutable ?? false, + }, + validator: built.validator, + } + }, a.variants.validator) } static dynamicUnion< VariantValues extends { @@ -931,22 +1017,69 @@ export class Value { name: string description?: string | null warning?: string | null + variants: Variants default: keyof VariantValues & string disabled: string[] | false | string }>, - aVariants: Variants, - ) { - return new Value(async (options) => { - const newValues = await getA(options) - return { - type: "union" as const, - description: null, - warning: null, - ...newValues, - variants: await aVariants.build(options as any), - immutable: false, + ): Value, unknown> + static dynamicUnion< + VariantValues extends StaticVariantValues, + StaticVariantValues extends { + [K in string]: { + name: string + spec: InputSpec } - }, aVariants.validator) + }, + >( + getA: LazyBuild<{ + name: string + description?: string | null + warning?: string | null + variants: Variants + default: keyof VariantValues & string + disabled: string[] | false | string + }>, + validator: Parser>, + ): Value< + UnionRes, + UnionResStaticValidatedAs + > + static dynamicUnion< + VariantValues extends { + [K in string]: { + name: string + spec: InputSpec + } + }, + >( + getA: LazyBuild<{ + name: string + description?: string | null + warning?: string | null + variants: Variants + default: keyof VariantValues & string + disabled: string[] | false | string + }>, + validator: Parser = any, + ) { + return new Value, typeof validator._TYPE>( + async (options) => { + const newValues = await getA(options) + const built = await newValues.variants.build(options as any) + return { + spec: { + type: "union" as const, + description: null, + warning: null, + ...newValues, + variants: built.spec, + immutable: false, + }, + validator: built.validator, + } + }, + validator, + ) } /** * @description Presents an interface to add/remove/edit items in a list. @@ -1022,16 +1155,45 @@ export class Value { hiddenExample: Value.hidden(), * ``` */ + static hidden(): Value + static hidden(parser: Parser): Value static hidden(parser: Parser = any) { - return new Value(async () => { - const built: ValueSpecHidden = { - type: "hidden" as const, + return new Value(async () => { + return { + spec: { + type: "hidden" as const, + } as ValueSpecHidden, + validator: parser, } - return built }, parser) } - map(fn: (value: Type) => U): Value { - return new Value(this.build, this.validator.map(fn)) + /** + * @description Provides a way to define a hidden field with a static value. Useful for tracking + * @example + * ``` + hiddenExample: Value.hidden(), + * ``` + */ + static dynamicHidden(getParser: LazyBuild>) { + return new Value(async (options) => { + const validator = await getParser(options) + return { + spec: { + type: "hidden" as const, + } as ValueSpecHidden, + validator, + } + }, any) + } + + map(fn: (value: StaticValidatedAs) => U): Value { + return new Value(async (effects) => { + const built = await this.build(effects) + return { + spec: built.spec, + validator: built.validator.map(fn), + } + }, this.validator.map(fn)) } } diff --git a/sdk/base/lib/actions/input/builder/variants.ts b/sdk/base/lib/actions/input/builder/variants.ts index 9830f7346..66298067c 100644 --- a/sdk/base/lib/actions/input/builder/variants.ts +++ b/sdk/base/lib/actions/input/builder/variants.ts @@ -4,9 +4,9 @@ import { LazyBuild, InputSpec, ExtractInputSpecType, - ExtractPartialInputSpecType, + ExtractInputSpecStaticValidatedAs, } from "./inputSpec" -import { Parser, anyOf, literal, object } from "ts-matches" +import { Parser, any, anyOf, literal, object } from "ts-matches" export type UnionRes< VariantValues extends { @@ -28,6 +28,26 @@ export type UnionRes< } }[K] +export type UnionResStaticValidatedAs< + VariantValues extends { + [K in string]: { + name: string + spec: InputSpec + } + }, + K extends keyof VariantValues & string = keyof VariantValues & string, +> = { + [key in keyof VariantValues]: { + selection: key + value: ExtractInputSpecStaticValidatedAs + other?: { + [key2 in Exclude]?: DeepPartial< + ExtractInputSpecStaticValidatedAs + > + } + } +}[K] + /** * Used in the the Value.select { @link './value.ts' } * to indicate the type of select variants that are available. The key for the record passed in will be the @@ -80,14 +100,21 @@ export class Variants< VariantValues extends { [K in string]: { name: string - spec: InputSpec + spec: InputSpec } }, > { private constructor( - public build: LazyBuild, - public validator: Parser>, + public build: LazyBuild<{ + spec: ValueSpecUnion["variants"] + validator: Parser> + }>, + public readonly validator: Parser< + unknown, + UnionResStaticValidatedAs + >, ) {} + readonly _TYPE: UnionRes = null as any static of< VariantValues extends { [K in string]: { @@ -96,30 +123,71 @@ export class Variants< } }, >(a: VariantValues) { - const validator = anyOf( - ...Object.entries(a).map(([id, { spec }]) => - object({ - selection: literal(id), - value: spec.validator, - }), + const staticValidators = {} as { + [K in keyof VariantValues]: Parser< + unknown, + ExtractInputSpecStaticValidatedAs + > + } + for (const key in a) { + const value = a[key] + staticValidators[key] = value.spec.validator + } + const other = object( + Object.fromEntries( + Object.entries(staticValidators).map(([k, v]) => [k, any.optional()]), ), - ) as Parser - - return new Variants(async (options) => { - const variants = {} as { - [K in keyof VariantValues]: { - name: string - spec: Record + ).optional() + return new Variants( + async (options) => { + const validators = {} as { + [K in keyof VariantValues]: Parser< + unknown, + ExtractInputSpecType + > } - } - for (const key in a) { - const value = a[key] - variants[key] = { - name: value.name, - spec: await value.spec.build(options as any), + const variants = {} as { + [K in keyof VariantValues]: { + name: string + spec: Record + } } - } - return variants - }, validator) + for (const key in a) { + const value = a[key] + const built = await value.spec.build(options as any) + variants[key] = { + name: value.name, + spec: built.spec, + } + validators[key] = built.validator + } + const other = object( + Object.fromEntries( + Object.entries(validators).map(([k, v]) => [k, any.optional()]), + ), + ).optional() + return { + spec: variants, + validator: anyOf( + ...Object.entries(validators).map(([k, v]) => + object({ + selection: literal(k), + value: v, + other, + }), + ), + ) as any, + } + }, + anyOf( + ...Object.entries(staticValidators).map(([k, v]) => + object({ + selection: literal(k), + value: v, + other, + }), + ), + ) as any, + ) } } diff --git a/sdk/base/lib/actions/input/inputSpecConstants.ts b/sdk/base/lib/actions/input/inputSpecConstants.ts index e1469d6e9..c1c19085d 100644 --- a/sdk/base/lib/actions/input/inputSpecConstants.ts +++ b/sdk/base/lib/actions/input/inputSpecConstants.ts @@ -7,7 +7,9 @@ import { Variants } from "./builder/variants" /** * Base SMTP settings, to be used by StartOS for system wide SMTP */ -export const customSmtp = InputSpec.of>({ +export const customSmtp: InputSpec = InputSpec.of< + InputSpecOf +>({ server: Value.text({ name: "SMTP Server", required: true, @@ -42,40 +44,39 @@ export const customSmtp = InputSpec.of>({ }), }) +const smtpVariants = Variants.of({ + disabled: { name: "Disabled", spec: InputSpec.of({}) }, + system: { + name: "System Credentials", + spec: InputSpec.of({ + customFrom: Value.text({ + name: "Custom From Address", + description: + "A custom from address for this service. If not provided, the system from address will be used.", + required: false, + default: null, + placeholder: "test@example.com", + inputmode: "email", + patterns: [Patterns.email], + }), + }), + }, + custom: { + name: "Custom Credentials", + spec: customSmtp, + }, +}) /** * For service inputSpec. Gives users 3 options for SMTP: (1) disabled, (2) use system SMTP settings, (3) use custom SMTP settings */ -export const smtpInputSpec = Value.dynamicUnion( - async ({ effects }) => { - const smtp = await new GetSystemSmtp(effects).once() - const disabled = smtp ? [] : ["system"] - return { - name: "SMTP", - description: "Optionally provide an SMTP server for sending emails", - default: "disabled", - disabled, - } - }, - Variants.of({ - disabled: { name: "Disabled", spec: InputSpec.of({}) }, - system: { - name: "System Credentials", - spec: InputSpec.of({ - customFrom: Value.text({ - name: "Custom From Address", - description: - "A custom from address for this service. If not provided, the system from address will be used.", - required: false, - default: null, - placeholder: "test@example.com", - inputmode: "email", - patterns: [Patterns.email], - }), - }), - }, - custom: { - name: "Custom Credentials", - spec: customSmtp, - }, - }), -) +export const smtpInputSpec = Value.dynamicUnion(async ({ effects }) => { + const smtp = await new GetSystemSmtp(effects).once() + const disabled = smtp ? [] : ["system"] + return { + name: "SMTP", + description: "Optionally provide an SMTP server for sending emails", + default: "disabled", + disabled, + variants: smtpVariants, + } +}, smtpVariants.validator) diff --git a/sdk/base/lib/actions/setupActions.ts b/sdk/base/lib/actions/setupActions.ts index e2c8e73f3..1cb3a8a3b 100644 --- a/sdk/base/lib/actions/setupActions.ts +++ b/sdk/base/lib/actions/setupActions.ts @@ -1,23 +1,17 @@ import { InputSpec } from "./input/builder" -import { - ExtractInputSpecType, - ExtractPartialInputSpecType, -} from "./input/builder/inputSpec" +import { ExtractInputSpecType } from "./input/builder/inputSpec" import * as T from "../types" import { once } from "../util" import { InitScript } from "../inits" +import { Parser } from "ts-matches" -export type Run< - A extends Record | InputSpec>, -> = (options: { +export type Run> = (options: { effects: T.Effects - input: ExtractInputSpecType + input: A }) => Promise<(T.ActionResult & { version: "1" }) | null | void | undefined> -export type GetInput< - A extends Record | InputSpec>, -> = (options: { +export type GetInput> = (options: { effects: T.Effects -}) => Promise> +}) => Promise> export type MaybeFn = T | ((options: { effects: T.Effects }) => Promise) function callMaybeFn( @@ -43,39 +37,38 @@ function mapMaybeFn( export interface ActionInfo< Id extends T.ActionId, - InputSpecType extends Record | InputSpec, + Type extends Record, > { readonly id: Id - readonly _INPUT: InputSpecType + readonly _INPUT: Type } -export class Action< - Id extends T.ActionId, - InputSpecType extends Record | InputSpec, -> implements ActionInfo +export class Action> + implements ActionInfo { - readonly _INPUT: InputSpecType = null as any as InputSpecType + readonly _INPUT: Type = null as any as Type + private cachedParser?: Parser private constructor( readonly id: Id, private readonly metadataFn: MaybeFn, - private readonly inputSpec: InputSpecType, - private readonly getInputFn: GetInput, - private readonly runFn: Run, + private readonly inputSpec: InputSpec, + private readonly getInputFn: GetInput, + private readonly runFn: Run, ) {} static withInput< Id extends T.ActionId, - InputSpecType extends Record | InputSpec, + InputSpecType extends InputSpec>, >( id: Id, metadata: MaybeFn>, inputSpec: InputSpecType, - getInput: GetInput, - run: Run, - ): Action { - return new Action( + getInput: GetInput>, + run: Run>, + ): Action> { + return new Action>( id, mapMaybeFn(metadata, (m) => ({ ...m, hasInput: true })), - inputSpec, + inputSpec as any, getInput, run, ) @@ -88,7 +81,7 @@ export class Action< return new Action( id, mapMaybeFn(metadata, (m) => ({ ...m, hasInput: false })), - {}, + InputSpec.of({}), async () => null, run, ) @@ -107,16 +100,27 @@ export class Action< return metadata } async getInput(options: { effects: T.Effects }): Promise { + const built = await this.inputSpec.build(options) + this.cachedParser = built.validator return { - spec: await this.inputSpec.build(options), + spec: built.spec, value: (await this.getInputFn(options)) || null, } } async run(options: { effects: T.Effects - input: ExtractInputSpecType + input: Type }): Promise { - return (await this.runFn(options)) || null + const parser = + this.cachedParser ?? (await this.inputSpec.build(options)).validator + return ( + (await this.runFn({ + effects: options.effects, + input: this.cachedParser + ? this.cachedParser.unsafeCast(options.input) + : options.input, + })) || null + ) } } diff --git a/sdk/base/lib/test/inputSpecTypes.test.ts b/sdk/base/lib/test/inputSpecTypes.test.ts index 6767faf20..5665fe1d7 100644 --- a/sdk/base/lib/test/inputSpecTypes.test.ts +++ b/sdk/base/lib/test/inputSpecTypes.test.ts @@ -15,10 +15,10 @@ describe("InputSpec Types", () => { { spec: InputSpec.of({}) } as any, ) as any const someList = await Value.list(test).build({} as any) - if (isValueSpecListOf(someList, "text")) { - someList.spec satisfies ListValueSpecOf<"text"> - } else if (isValueSpecListOf(someList, "object")) { - someList.spec satisfies ListValueSpecOf<"object"> + if (isValueSpecListOf(someList.spec, "text")) { + someList.spec.spec satisfies ListValueSpecOf<"text"> + } else if (isValueSpecListOf(someList.spec, "object")) { + someList.spec.spec satisfies ListValueSpecOf<"object"> } else { throw new Error( "Failed to figure out the type: " + JSON.stringify(someList), diff --git a/sdk/package/lib/StartSdk.ts b/sdk/package/lib/StartSdk.ts index 356258e2c..c65e64747 100644 --- a/sdk/package/lib/StartSdk.ts +++ b/sdk/package/lib/StartSdk.ts @@ -333,16 +333,7 @@ export class StartSdk { ) * ``` */ - withInput: < - Id extends T.ActionId, - InputSpecType extends Record | InputSpec, - >( - id: Id, - metadata: MaybeFn>, - inputSpec: InputSpecType, - getInput: GetInput, - run: Run, - ) => Action.withInput(id, metadata, inputSpec, getInput, run), + withInput: Action.withInput, /** * @description Use this function to create an action that does not accept form input * @param id - a unique ID for this action diff --git a/sdk/package/lib/test/inputSpecBuilder.test.ts b/sdk/package/lib/test/inputSpecBuilder.test.ts index 6864f6ed8..a1db193bd 100644 --- a/sdk/package/lib/test/inputSpecBuilder.test.ts +++ b/sdk/package/lib/test/inputSpecBuilder.test.ts @@ -18,7 +18,9 @@ describe("builder tests", () => { required: true, default: null, }), - }).build({} as any) + }) + .build({} as any) + .then((a) => a.spec) expect(bitcoinPropertiesBuilt).toMatchObject({ "peer-tor-address": { type: "text", @@ -41,66 +43,66 @@ describe("builder tests", () => { describe("values", () => { test("toggle", async () => { - const value = Value.toggle({ + const value = await Value.toggle({ name: "Testing", description: null, warning: null, default: false, - }) + }).build({} as any) const validator = value.validator validator.unsafeCast(false) testOutput()(null) }) test("text", async () => { - const value = Value.text({ + const value = await Value.text({ name: "Testing", required: true, default: null, - }) + }).build({} as any) const validator = value.validator - const rawIs = await value.build({} as any) + const rawIs = value.spec validator.unsafeCast("test text") expect(() => validator.unsafeCast(null)).toThrowError() testOutput()(null) }) test("text with default", async () => { - const value = Value.text({ + const value = await Value.text({ name: "Testing", required: true, default: "this is a default value", - }) + }).build({} as any) const validator = value.validator - const rawIs = await value.build({} as any) + const rawIs = value.spec validator.unsafeCast("test text") expect(() => validator.unsafeCast(null)).toThrowError() testOutput()(null) }) test("optional text", async () => { - const value = Value.text({ + const value = await Value.text({ name: "Testing", required: false, default: null, - }) + }).build({} as any) const validator = value.validator - const rawIs = await value.build({} as any) + const rawIs = value.spec validator.unsafeCast("test text") validator.unsafeCast(null) testOutput()(null) }) test("color", async () => { - const value = Value.color({ + const value = await Value.color({ name: "Testing", required: false, default: null, description: null, warning: null, - }) + }).build({} as any) const validator = value.validator validator.unsafeCast("#000000") testOutput()(null) }) test("datetime", async () => { - const value = Value.datetime({ + const value = await Value.datetime({ name: "Testing", required: true, default: null, @@ -109,13 +111,13 @@ describe("values", () => { inputmode: "date", min: null, max: null, - }) + }).build({} as any) const validator = value.validator validator.unsafeCast("2021-01-01") testOutput()(null) }) test("optional datetime", async () => { - const value = Value.datetime({ + const value = await Value.datetime({ name: "Testing", required: false, default: null, @@ -124,13 +126,13 @@ describe("values", () => { inputmode: "date", min: null, max: null, - }) + }).build({} as any) const validator = value.validator validator.unsafeCast("2021-01-01") testOutput()(null) }) test("textarea", async () => { - const value = Value.textarea({ + const value = await Value.textarea({ name: "Testing", required: false, default: null, @@ -139,13 +141,13 @@ describe("values", () => { minLength: null, maxLength: null, placeholder: null, - }) + }).build({} as any) const validator = value.validator validator.unsafeCast("test text") testOutput()(null) }) test("number", async () => { - const value = Value.number({ + const value = await Value.number({ name: "Testing", required: true, default: null, @@ -157,13 +159,13 @@ describe("values", () => { step: null, units: null, placeholder: null, - }) + }).build({} as any) const validator = value.validator validator.unsafeCast(2) testOutput()(null) }) test("optional number", async () => { - const value = Value.number({ + const value = await Value.number({ name: "Testing", required: false, default: null, @@ -175,13 +177,13 @@ describe("values", () => { step: null, units: null, placeholder: null, - }) + }).build({} as any) const validator = value.validator validator.unsafeCast(2) testOutput()(null) }) test("select", async () => { - const value = Value.select({ + const value = await Value.select({ name: "Testing", default: "a", values: { @@ -190,7 +192,7 @@ describe("values", () => { }, description: null, warning: null, - }) + }).build({} as any) const validator = value.validator validator.unsafeCast("a") validator.unsafeCast("b") @@ -198,7 +200,7 @@ describe("values", () => { testOutput()(null) }) test("nullable select", async () => { - const value = Value.select({ + const value = await Value.select({ name: "Testing", default: "a", values: { @@ -207,14 +209,14 @@ describe("values", () => { }, description: null, warning: null, - }) + }).build({} as any) const validator = value.validator validator.unsafeCast("a") validator.unsafeCast("b") testOutput()(null) }) test("multiselect", async () => { - const value = Value.multiselect({ + const value = await Value.multiselect({ name: "Testing", values: { a: "A", @@ -225,7 +227,7 @@ describe("values", () => { warning: null, minLength: null, maxLength: null, - }) + }).build({} as any) const validator = value.validator validator.unsafeCast([]) validator.unsafeCast(["a", "b"]) @@ -235,7 +237,7 @@ describe("values", () => { testOutput>()(null) }) test("object", async () => { - const value = Value.object( + const value = await Value.object( { name: "Testing", description: null, @@ -248,20 +250,18 @@ describe("values", () => { default: false, }), }), - ) + ).build({} as any) const validator = value.validator validator.unsafeCast({ a: true }) testOutput()(null) }) test("union", async () => { - const value = Value.union( - { - name: "Testing", - default: "a", - description: null, - warning: null, - }, - Variants.of({ + const value = await Value.union({ + name: "Testing", + default: "a", + description: null, + warning: null, + variants: Variants.of({ a: { name: "a", spec: InputSpec.of({ @@ -274,7 +274,7 @@ describe("values", () => { }), }, }), - ) + }).build({} as any) const validator = value.validator validator.unsafeCast({ selection: "a", value: { b: false } }) type Test = typeof validator._TYPE @@ -297,17 +297,17 @@ describe("values", () => { utils: "utils", } as any test("toggle", async () => { - const value = Value.dynamicToggle(async () => ({ + const value = await Value.dynamicToggle(async () => ({ name: "Testing", description: null, warning: null, default: false, - })) + })).build({} as any) const validator = value.validator validator.unsafeCast(false) expect(() => validator.unsafeCast(null)).toThrowError() testOutput()(null) - expect(await value.build(fakeOptions)).toMatchObject({ + expect(value.spec).toMatchObject({ name: "Testing", description: null, warning: null, @@ -315,68 +315,68 @@ describe("values", () => { }) }) test("text", async () => { - const value = Value.dynamicText(async () => ({ + const value = await Value.dynamicText(async () => ({ name: "Testing", required: false, default: null, - })) + })).build({} as any) const validator = value.validator - const rawIs = await value.build({} as any) + const rawIs = value.spec validator.unsafeCast("test text") validator.unsafeCast(null) testOutput()(null) - expect(await value.build(fakeOptions)).toMatchObject({ + expect(value.spec).toMatchObject({ name: "Testing", required: false, default: null, }) }) test("text with default", async () => { - const value = Value.dynamicText(async () => ({ + const value = await Value.dynamicText(async () => ({ name: "Testing", required: false, default: "this is a default value", - })) + })).build({} as any) const validator = value.validator validator.unsafeCast("test text") validator.unsafeCast(null) testOutput()(null) - expect(await value.build(fakeOptions)).toMatchObject({ + expect(value.spec).toMatchObject({ name: "Testing", required: false, default: "this is a default value", }) }) test("optional text", async () => { - const value = Value.dynamicText(async () => ({ + const value = await Value.dynamicText(async () => ({ name: "Testing", required: false, default: null, - })) + })).build({} as any) const validator = value.validator - const rawIs = await value.build({} as any) + const rawIs = value.spec validator.unsafeCast("test text") validator.unsafeCast(null) testOutput()(null) - expect(await value.build(fakeOptions)).toMatchObject({ + expect(value.spec).toMatchObject({ name: "Testing", required: false, default: null, }) }) test("color", async () => { - const value = Value.dynamicColor(async () => ({ + const value = await Value.dynamicColor(async () => ({ name: "Testing", required: false, default: null, description: null, warning: null, - })) + })).build({} as any) const validator = value.validator validator.unsafeCast("#000000") validator.unsafeCast(null) testOutput()(null) - expect(await value.build(fakeOptions)).toMatchObject({ + expect(value.spec).toMatchObject({ name: "Testing", required: false, default: null, @@ -423,21 +423,21 @@ describe("values", () => { ) .build(true) - const value = Value.dynamicDatetime(async ({ effects }) => { + const value = await Value.dynamicDatetime(async ({ effects }) => { return { name: "Testing", - required: true, + required: false, default: null, inputmode: "date", } - }) + }).build({} as any) const validator = value.validator validator.unsafeCast("2021-01-01") validator.unsafeCast(null) testOutput()(null) - expect(await value.build(fakeOptions)).toMatchObject({ + expect(value.spec).toMatchObject({ name: "Testing", - required: true, + required: false, default: null, description: null, warning: null, @@ -445,7 +445,7 @@ describe("values", () => { }) }) test("textarea", async () => { - const value = Value.dynamicTextarea(async () => ({ + const value = await Value.dynamicTextarea(async () => ({ name: "Testing", required: false, default: null, @@ -454,19 +454,19 @@ describe("values", () => { minLength: null, maxLength: null, placeholder: null, - })) + })).build({} as any) const validator = value.validator validator.unsafeCast("test text") testOutput()(null) - expect(await value.build(fakeOptions)).toMatchObject({ + expect(value.spec).toMatchObject({ name: "Testing", required: false, }) }) test("number", async () => { - const value = Value.dynamicNumber(() => ({ + const value = await Value.dynamicNumber(() => ({ name: "Testing", - required: true, + required: false, default: null, integer: false, description: null, @@ -476,19 +476,19 @@ describe("values", () => { step: null, units: null, placeholder: null, - })) + })).build({} as any) const validator = value.validator validator.unsafeCast(2) validator.unsafeCast(null) expect(() => validator.unsafeCast("null")).toThrowError() testOutput()(null) - expect(await value.build(fakeOptions)).toMatchObject({ + expect(value.spec).toMatchObject({ name: "Testing", - required: true, + required: false, }) }) test("select", async () => { - const value = Value.dynamicSelect(() => ({ + const value = await Value.dynamicSelect(() => ({ name: "Testing", default: "a", values: { @@ -497,18 +497,17 @@ describe("values", () => { }, description: null, warning: null, - })) + })).build({} as any) const validator = value.validator validator.unsafeCast("a") validator.unsafeCast("b") - validator.unsafeCast("c") - testOutput()(null) - expect(await value.build(fakeOptions)).toMatchObject({ + testOutput()(null) + expect(value.spec).toMatchObject({ name: "Testing", }) }) test("multiselect", async () => { - const value = Value.dynamicMultiselect(() => ({ + const value = await Value.dynamicMultiselect(() => ({ name: "Testing", values: { a: "A", @@ -519,16 +518,15 @@ describe("values", () => { warning: null, minLength: null, maxLength: null, - })) + })).build({} as any) const validator = value.validator validator.unsafeCast([]) validator.unsafeCast(["a", "b"]) - validator.unsafeCast(["c"]) expect(() => validator.unsafeCast([4])).toThrowError() expect(() => validator.unsafeCast(null)).toThrowError() - testOutput>()(null) - expect(await value.build(fakeOptions)).toMatchObject({ + testOutput>()(null) + expect(value.spec).toMatchObject({ name: "Testing", default: [], }) @@ -536,15 +534,13 @@ describe("values", () => { }) describe("filtering", () => { test("union", async () => { - const value = Value.dynamicUnion( - () => ({ - name: "Testing", - default: "a", - description: null, - warning: null, - disabled: ["a", "c"], - }), - Variants.of({ + const value = await Value.dynamicUnion(() => ({ + name: "Testing", + default: "a", + description: null, + warning: null, + disabled: ["a", "c"], + variants: Variants.of({ a: { name: "a", spec: InputSpec.of({ @@ -568,7 +564,7 @@ describe("values", () => { }), }, }), - ) + })).build({} as any) const validator = value.validator validator.unsafeCast({ selection: "a", value: { b: false } }) type Test = typeof validator._TYPE @@ -598,7 +594,7 @@ describe("values", () => { } >()(null) - const built = await value.build({} as any) + const built = value.spec expect(built).toMatchObject({ name: "Testing", variants: { @@ -623,15 +619,13 @@ describe("values", () => { }) }) test("dynamic union", async () => { - const value = Value.dynamicUnion( - () => ({ - disabled: ["a", "c"], - name: "Testing", - default: "b", - description: null, - warning: null, - }), - Variants.of({ + const value = await Value.dynamicUnion(() => ({ + disabled: ["a", "c"], + name: "Testing", + default: "b", + description: null, + warning: null, + variants: Variants.of({ a: { name: "a", spec: InputSpec.of({ @@ -655,7 +649,7 @@ describe("values", () => { }), }, }), - ) + })).build({} as any) const validator = value.validator validator.unsafeCast({ selection: "a", value: { b: false } }) type Test = typeof validator._TYPE @@ -685,7 +679,7 @@ describe("values", () => { } >()(null) - const built = await value.build({} as any) + const built = value.spec expect(built).toMatchObject({ name: "Testing", variants: { @@ -712,7 +706,7 @@ describe("values", () => { describe("Builder List", () => { test("obj", async () => { - const value = Value.list( + const value = await Value.list( List.obj( { name: "test", @@ -728,13 +722,13 @@ describe("Builder List", () => { }), }, ), - ) + ).build({} as any) const validator = value.validator validator.unsafeCast([{ test: true }]) testOutput()(null) }) test("text", async () => { - const value = Value.list( + const value = await Value.list( List.text( { name: "test", @@ -743,25 +737,25 @@ describe("Builder List", () => { patterns: [], }, ), - ) + ).build({} as any) const validator = value.validator validator.unsafeCast(["test", "text"]) testOutput()(null) }) describe("dynamic", () => { test("text", async () => { - const value = Value.list( + const value = await Value.list( List.dynamicText(() => ({ name: "test", spec: { patterns: [] }, })), - ) + ).build({} as any) const validator = value.validator validator.unsafeCast(["test", "text"]) expect(() => validator.unsafeCast([3, 4])).toThrowError() expect(() => validator.unsafeCast(null)).toThrowError() testOutput()(null) - expect(await value.build({} as any)).toMatchObject({ + expect(value.spec).toMatchObject({ name: "test", spec: { patterns: [] }, }) @@ -771,7 +765,7 @@ describe("Builder List", () => { describe("Nested nullable values", () => { test("Testing text", async () => { - const value = InputSpec.of({ + const value = await InputSpec.of({ a: Value.text({ name: "Temp Name", description: @@ -779,7 +773,7 @@ describe("Nested nullable values", () => { required: false, default: null, }), - }) + }).build({} as any) const validator = value.validator validator.unsafeCast({ a: null }) validator.unsafeCast({ a: "test" }) @@ -787,7 +781,7 @@ describe("Nested nullable values", () => { testOutput()(null) }) test("Testing number", async () => { - const value = InputSpec.of({ + const value = await InputSpec.of({ a: Value.number({ name: "Temp Name", description: @@ -802,7 +796,7 @@ describe("Nested nullable values", () => { step: null, units: null, }), - }) + }).build({} as any) const validator = value.validator validator.unsafeCast({ a: null }) validator.unsafeCast({ a: 5 }) @@ -810,7 +804,7 @@ describe("Nested nullable values", () => { testOutput()(null) }) test("Testing color", async () => { - const value = InputSpec.of({ + const value = await InputSpec.of({ a: Value.color({ name: "Temp Name", description: @@ -819,7 +813,7 @@ describe("Nested nullable values", () => { default: null, warning: null, }), - }) + }).build({} as any) const validator = value.validator validator.unsafeCast({ a: null }) validator.unsafeCast({ a: "5" }) @@ -827,7 +821,7 @@ describe("Nested nullable values", () => { testOutput()(null) }) test("Testing select", async () => { - const value = InputSpec.of({ + const value = await InputSpec.of({ a: Value.select({ name: "Temp Name", description: @@ -838,7 +832,7 @@ describe("Nested nullable values", () => { a: "A", }, }), - }) + }).build({} as any) const higher = await Value.select({ name: "Temp Name", description: @@ -856,7 +850,7 @@ describe("Nested nullable values", () => { testOutput()(null) }) test("Testing multiselect", async () => { - const value = InputSpec.of({ + const value = await InputSpec.of({ a: Value.multiselect({ name: "Temp Name", description: @@ -870,7 +864,7 @@ describe("Nested nullable values", () => { minLength: null, maxLength: null, }), - }) + }).build({} as any) const validator = value.validator validator.unsafeCast({ a: [] }) validator.unsafeCast({ a: ["a"] }) diff --git a/sdk/package/lib/test/output.test.ts b/sdk/package/lib/test/output.test.ts index a2e9c35d5..4f1321484 100644 --- a/sdk/package/lib/test/output.test.ts +++ b/sdk/package/lib/test/output.test.ts @@ -1,4 +1,4 @@ -import { InputSpecSpec, matchInputSpecSpec } from "./output" +import { inputSpecSpec, InputSpecSpec } from "./output" import * as _I from "../index" import { camelCase } from "../../scripts/oldSpecToBuilder" import { deepMerge } from "../../../base/lib/util" @@ -97,25 +97,27 @@ describe("Inputs", () => { }, } - test("test valid input", () => { - const output = matchInputSpecSpec.unsafeCast(validInput) + test("test valid input", async () => { + const { validator } = await inputSpecSpec.build({} as any) + const output = validator.unsafeCast(validInput) expect(output).toEqual(validInput) }) - test("test no longer care about the conversion of min/max and validating", () => { - matchInputSpecSpec.unsafeCast( + test("test no longer care about the conversion of min/max and validating", async () => { + const { validator } = await inputSpecSpec.build({} as any) + validator.unsafeCast( deepMerge({}, validInput, { rpc: { advanced: { threads: 0 } } }), ) }) - test("test errors should throw for number in string", () => { + test("test errors should throw for number in string", async () => { + const { validator } = await inputSpecSpec.build({} as any) expect(() => - matchInputSpecSpec.unsafeCast( - deepMerge({}, validInput, { rpc: { enable: 2 } }), - ), + validator.unsafeCast(deepMerge({}, validInput, { rpc: { enable: 2 } })), ).toThrowError() }) - test("Test that we set serialversion to something not segwit or non-segwit", () => { + test("Test that we set serialversion to something not segwit or non-segwit", async () => { + const { validator } = await inputSpecSpec.build({} as any) expect(() => - matchInputSpecSpec.unsafeCast( + validator.unsafeCast( deepMerge({}, validInput, { rpc: { advanced: { serialversion: "testing" } }, }), diff --git a/sdk/package/lib/version/VersionGraph.ts b/sdk/package/lib/version/VersionGraph.ts index 64dec0683..adb6a303d 100644 --- a/sdk/package/lib/version/VersionGraph.ts +++ b/sdk/package/lib/version/VersionGraph.ts @@ -134,19 +134,15 @@ export class VersionGraph for (let rangeStr in version.options.migrations.other) { const range = VersionRange.parse(rangeStr) const vRange = graph.addVertex(range, [], []) - graph.addEdge( - version.options.migrations.other[rangeStr], - vRange, - vertex, - ) + const migration = version.options.migrations.other[rangeStr] + if (migration.up) graph.addEdge(migration.up, vRange, vertex) + if (migration.down) graph.addEdge(migration.down, vertex, vRange) for (let matching of graph.findVertex( (v) => isExver(v.metadata) && v.metadata.satisfies(range), )) { - graph.addEdge( - version.options.migrations.other[rangeStr], - matching, - vertex, - ) + if (migration.up) graph.addEdge(migration.up, matching, vertex) + if (migration.down) + graph.addEdge(migration.down, vertex, matching) } } } diff --git a/sdk/package/lib/version/VersionInfo.ts b/sdk/package/lib/version/VersionInfo.ts index 952ae5352..e52c71346 100644 --- a/sdk/package/lib/version/VersionInfo.ts +++ b/sdk/package/lib/version/VersionInfo.ts @@ -23,7 +23,13 @@ export type VersionOptions = { /** * Additional migrations, such as fast-forward migrations, or migrations from other flavors. */ - other?: Record Promise> + other?: Record< + string, + { + up?: (opts: { effects: T.Effects }) => Promise + down?: (opts: { effects: T.Effects }) => Promise + } + > } } diff --git a/sdk/package/package-lock.json b/sdk/package/package-lock.json index af340b6ec..f9a92462e 100644 --- a/sdk/package/package-lock.json +++ b/sdk/package/package-lock.json @@ -1,12 +1,12 @@ { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.30", + "version": "0.4.0-beta.32", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.30", + "version": "0.4.0-beta.32", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", diff --git a/sdk/package/package.json b/sdk/package/package.json index d9df3c7cf..df85202bc 100644 --- a/sdk/package/package.json +++ b/sdk/package/package.json @@ -1,6 +1,6 @@ { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.30", + "version": "0.4.0-beta.32", "description": "Software development kit to facilitate packaging services for StartOS", "main": "./package/lib/index.js", "types": "./package/lib/index.d.ts", diff --git a/sdk/package/scripts/oldSpecToBuilder.ts b/sdk/package/scripts/oldSpecToBuilder.ts index 11ef30340..2acaf21f6 100644 --- a/sdk/package/scripts/oldSpecToBuilder.ts +++ b/sdk/package/scripts/oldSpecToBuilder.ts @@ -39,13 +39,7 @@ const {InputSpec, List, Value, Variants} = sdk const namedConsts = new Set(["InputSpec", "Value", "List"]) const inputSpecName = newConst("inputSpecSpec", convertInputSpec(data)) - const inputSpecMatcherName = newConst( - "matchInputSpecSpec", - `${inputSpecName}.validator`, - ) - outputLines.push( - `export type InputSpecSpec = typeof ${inputSpecMatcherName}._TYPE;`, - ) + outputLines.push(`export type InputSpecSpec = typeof ${inputSpecName}._TYPE;`) return outputLines.join("\n") @@ -195,7 +189,8 @@ const {InputSpec, List, Value, Variants} = sdk description: ${JSON.stringify(value.tag.description || null)}, warning: ${JSON.stringify(value.tag.warning || null)}, default: ${JSON.stringify(value.default)}, - }, ${variants})` + variants: ${variants}, + })` } case "list": { if (value.subtype === "enum") { @@ -322,7 +317,8 @@ const {InputSpec, List, Value, Variants} = sdk )}, warning: ${JSON.stringify(value?.spec?.tag?.warning || null)}, default: ${JSON.stringify(value?.spec?.default || null)}, - }, ${variants}) + variants: ${variants}, + }) `, ) const listInputSpec = maybeNewConst( diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/acme/acme.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/acme/acme.component.ts index f15aee68c..0e0f66f98 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/acme/acme.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/acme/acme.component.ts @@ -232,12 +232,10 @@ export default class SystemAcmeComponent { ) return ISB.InputSpec.of({ - provider: ISB.Value.union( - { - name: 'Provider', - default: (availableAcme[0]?.url as any) || 'other', - }, - ISB.Variants.of({ + provider: ISB.Value.union({ + name: 'Provider', + default: (availableAcme[0]?.url as any) || 'other', + variants: ISB.Variants.of({ ...availableAcme.reduce( (obj, curr) => ({ ...obj, @@ -261,7 +259,7 @@ export default class SystemAcmeComponent { }), }, }), - ), + }), contact: this.emailListSpec(), }) } diff --git a/web/projects/ui/src/app/services/api/api.fixures.ts b/web/projects/ui/src/app/services/api/api.fixures.ts index 0742d1796..0ffe52cd2 100644 --- a/web/projects/ui/src/app/services/api/api.fixures.ts +++ b/web/projects/ui/src/app/services/api/api.fixures.ts @@ -1370,14 +1370,12 @@ export namespace Mock { 'RPC and P2P interface configuration options for Bitcoin Core', }, ISB.InputSpec.of({ - 'bitcoind-p2p': ISB.Value.union( - { - name: 'P2P Settings', - description: - '

The Bitcoin Core node to connect to over the peer-to-peer (P2P) interface:

  • Bitcoin Core: The Bitcoin Core service installed on this device
  • External Node: A Bitcoin node running on a different device
', - default: 'internal', - }, - ISB.Variants.of({ + 'bitcoind-p2p': ISB.Value.union({ + name: 'P2P Settings', + description: + '

The Bitcoin Core node to connect to over the peer-to-peer (P2P) interface:

  • Bitcoin Core: The Bitcoin Core service installed on this device
  • External Node: A Bitcoin node running on a different device
', + default: 'internal', + variants: ISB.Variants.of({ internal: { name: 'Bitcoin Core', spec: ISB.InputSpec.of({}) }, external: { name: 'External Node', @@ -1402,7 +1400,7 @@ export namespace Mock { }), }, }), - ), + }), }), ), color: ISB.Value.color({ @@ -1558,14 +1556,12 @@ export namespace Mock { }, { spec: ISB.InputSpec.of({ - union: ISB.Value.union( - { - name: 'Preference', - description: null, - warning: null, - default: 'summer', - }, - ISB.Variants.of({ + union: ISB.Value.union({ + name: 'Preference', + description: null, + warning: null, + default: 'summer', + variants: ISB.Variants.of({ summer: { name: 'summer', spec: ISB.InputSpec.of({ @@ -1599,7 +1595,7 @@ export namespace Mock { }), }, }), - ), + }), }), uniqueBy: 'preference', }, @@ -1715,14 +1711,12 @@ export namespace Mock { }), }), ), - 'bitcoin-node': ISB.Value.union( - { - name: 'Bitcoin Node', - description: 'Options
  • Item 1
  • Item 2
', - warning: 'Careful changing this', - default: 'internal', - }, - ISB.Variants.of({ + 'bitcoin-node': ISB.Value.union({ + name: 'Bitcoin Node', + description: 'Options
  • Item 1
  • Item 2
', + warning: 'Careful changing this', + default: 'internal', + variants: ISB.Variants.of({ fake: { name: 'Fake', spec: ISB.InputSpec.of({}), @@ -1818,7 +1812,7 @@ export namespace Mock { }), }, }), - ), + }), port: ISB.Value.number({ name: 'Port', description: diff --git a/web/projects/ui/src/app/utils/configBuilderToSpec.ts b/web/projects/ui/src/app/utils/configBuilderToSpec.ts index 19ffa1d5e..b2eee7dbf 100644 --- a/web/projects/ui/src/app/utils/configBuilderToSpec.ts +++ b/web/projects/ui/src/app/utils/configBuilderToSpec.ts @@ -3,5 +3,5 @@ import { ISB } from '@start9labs/start-sdk' export async function configBuilderToSpec( builder: ISB.InputSpec>, ) { - return builder.build({} as any) + return builder.build({} as any).then(a => a.spec) }