Feature/more dynamic unions (#2972)

* with validators

* more dynamic unions

* fixes from v31

* better constructor for dynamic unions

* version bump

* fix build
This commit is contained in:
Aiden McClelland
2025-07-01 17:40:39 -06:00
committed by GitHub
parent 35d2ec8a44
commit 340775a593
21 changed files with 863 additions and 611 deletions

View File

@@ -46,7 +46,7 @@ export const runAction = async <
}
}
type GetActionInputType<A extends ActionInfo<T.ActionId, any>> =
A extends Action<T.ActionId, infer I> ? ExtractInputSpecType<I> : never
A extends Action<T.ActionId, infer I> ? I : never
type TaskBase = {
reason?: string

View File

@@ -13,13 +13,17 @@ export type LazyBuild<ExpectedOut> = (
) => Promise<ExpectedOut> | ExpectedOut
// prettier-ignore
export type ExtractInputSpecType<A extends Record<string, any> | InputSpec<Record<string, any>>> =
A extends InputSpec<infer B> ? B :
A
export type ExtractInputSpecType<A extends InputSpec<Record<string, any>, any>> =
A extends InputSpec<infer B, any> ? B :
never
export type ExtractPartialInputSpecType<
A extends Record<string, any> | InputSpec<Record<string, any>>,
> = A extends InputSpec<infer B> ? DeepPartial<B> : DeepPartial<A>
export type ExtractInputSpecStaticValidatedAs<
A extends InputSpec<any, Record<string, any>>,
> = A extends InputSpec<any, infer B> ? B : never
// export type ExtractPartialInputSpecType<
// A extends Record<string, any> | InputSpec<Record<string, any>>,
// > = A extends InputSpec<infer B> ? DeepPartial<B> : DeepPartial<A>
export type InputSpecOf<A extends Record<string, any>> = {
[K in keyof A]: Value<A[K]>
@@ -82,35 +86,54 @@ export const addNodesSpec = InputSpec.of({ hostname: hostname, port: port });
```
*/
export class InputSpec<Type extends Record<string, any>> {
export class InputSpec<
Type extends StaticValidatedAs,
StaticValidatedAs extends Record<string, any> = Type,
> {
private constructor(
private readonly spec: {
[K in keyof Type]: Value<Type[K]>
},
public validator: Parser<unknown, Type>,
public readonly validator: Parser<unknown, StaticValidatedAs>,
) {}
public _TYPE: Type = null as any as Type
public _PARTIAL: DeepPartial<Type> = null as any as DeepPartial<Type>
async build(options: LazyBuildOptions) {
async build(options: LazyBuildOptions): Promise<{
spec: {
[K in keyof Type]: ValueSpec
}
validator: Parser<unknown, Type>
}> {
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<unknown, any>
}
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 extends Record<string, Value<any>>>(spec: Spec) {
const validatorObj = {} as {
[K in keyof Spec]: Parser<unknown, any>
}
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<infer T> ? T : never
}>(spec, validator as any)
static of<Spec extends Record<string, Value<any, any>>>(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<infer T, any> ? T : never
},
{
[K in keyof Spec]: Spec[K] extends Value<any, infer T> ? T : never
}
>(spec, validator as any)
}
}

View File

@@ -9,11 +9,15 @@ import {
} from "../inputSpecTypes"
import { Parser, arrayOf, string } from "ts-matches"
export class List<Type> {
export class List<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
private constructor(
public build: LazyBuild<ValueSpecList>,
public validator: Parser<unknown, Type>,
public build: LazyBuild<{
spec: ValueSpecList
validator: Parser<unknown, Type>
}>,
public readonly validator: Parser<unknown, StaticValidatedAs>,
) {}
readonly _TYPE: Type = null as any
static text(
a: {
@@ -58,6 +62,7 @@ export class List<Type> {
generate?: null | RandomString
},
) {
const validator = arrayOf(string)
return new List<string[]>(() => {
const spec = {
type: "text" as const,
@@ -81,8 +86,8 @@ export class List<Type> {
...a,
spec,
}
return built
}, arrayOf(string))
return { spec: built, validator }
}, validator)
}
static dynamicText(
@@ -105,6 +110,7 @@ export class List<Type> {
}
}>,
) {
const validator = arrayOf(string)
return new List<string[]>(async (options) => {
const { spec: aSpec, ...a } = await getA(options)
const spec = {
@@ -129,11 +135,15 @@ export class List<Type> {
...a,
spec,
}
return built
}, arrayOf(string))
return { spec: built, validator }
}, validator)
}
static obj<Type extends Record<string, any>>(
static obj<
Type extends StaticValidatedAs,
StaticValidatedAs extends Record<string, any>,
>(
a: {
name: string
description?: string | null
@@ -143,20 +153,20 @@ export class List<Type> {
maxLength?: number | null
},
aSpec: {
spec: InputSpec<Type>
spec: InputSpec<Type, StaticValidatedAs>
displayAs?: null | string
uniqueBy?: null | UniqueBy
},
) {
return new List<Type[]>(async (options) => {
return new List<Type[], StaticValidatedAs[]>(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<Type> {
...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))
}

View File

@@ -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<T, Required extends boolean> = Required extends true
const testForAsRequiredParser = once(
() => object({ required: literal(true) }).test,
)
function asRequiredParser<
Type,
Input,
Return extends Parser<unknown, Type> | Parser<unknown, Type | null>,
>(parser: Parser<unknown, Type>, input: Input): Return {
function asRequiredParser<Type, Input extends { required: boolean }>(
parser: Parser<unknown, Type>,
input: Input,
): Parser<unknown, AsRequired<Type, Input["required"]>> {
if (testForAsRequiredParser()(input)) return parser as any
return parser.nullable() as any
}
export class Value<Type> {
export class Value<Type extends StaticValidatedAs, StaticValidatedAs = Type> {
protected constructor(
public build: LazyBuild<ValueSpec>,
public validator: Parser<unknown, Type>,
public build: LazyBuild<{
spec: ValueSpec
validator: Parser<unknown, Type>
}>,
public readonly validator: Parser<unknown, StaticValidatedAs>,
) {}
public _TYPE: Type = null as any as Type
public _PARTIAL: DeepPartial<Type> = null as any as DeepPartial<Type>
@@ -79,16 +81,20 @@ export class Value<Type> {
*/
immutable?: boolean
}) {
const validator = boolean
return new Value<boolean>(
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<Type> {
disabled?: false | string
}>,
) {
const validator = boolean
return new Value<boolean>(
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<Type> {
*/
generate?: RandomString | null
}) {
const validator = asRequiredParser(string, a)
return new Value<AsRequired<string, Required>>(
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<Required extends boolean>(
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<Type> {
generate?: null | RandomString
}>,
) {
return new Value<string | null>(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<AsRequired<string, Required>, 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<Type> {
*/
immutable?: boolean
}) {
return new Value<AsRequired<string, Required>>(
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<string | null>(async (options) => {
const a = await getA(options)
return {
const validator = asRequiredParser(string, a)
return new Value<AsRequired<string, Required>>(async () => {
const built: ValueSpecTextarea = {
description: null,
warning: null,
minLength: null,
@@ -319,10 +308,45 @@ export class Value<Type> {
placeholder: null,
type: "textarea" as const,
disabled: false,
immutable: false,
immutable: a.immutable ?? false,
...a,
}
}, string.nullable())
return { spec: built, validator }
}, validator)
}
static dynamicTextarea<Required extends boolean>(
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<AsRequired<string, Required>, 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<Type> {
*/
immutable?: boolean
}) {
const validator = asRequiredParser(number, a)
return new Value<AsRequired<number, Required>>(
() => ({
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<Required extends boolean>(
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<Type> {
disabled?: false | string
}>,
) {
return new Value<number | null>(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<AsRequired<number, Required>, 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<Type> {
*/
immutable?: boolean
}) {
const validator = asRequiredParser(string, a)
return new Value<AsRequired<string, Required>>(
() => ({
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<Required extends boolean>(
getA: LazyBuild<{
name: string
description?: string | null
warning?: string | null
default: string | null
required: boolean
required: Required
disabled?: false | string
}>,
) {
return new Value<string | null>(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<AsRequired<string, Required>, 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<Type> {
*/
immutable?: boolean
}) {
const validator = asRequiredParser(string, a)
return new Value<AsRequired<string, Required>>(
() => ({
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<Required extends boolean>(
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<string | null>(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<AsRequired<string, Required>, 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<Type> {
*/
immutable?: boolean
}) {
const validator = anyOf(
...Object.keys(a.values).map((x: keyof Values & string) => literal(x)),
)
return new Value<keyof Values & string>(
() => ({
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<Values extends Record<string, string>>(
getA: LazyBuild<{
name: string
description?: string | null
warning?: string | null
default: string
values: Record<string, string>
values: Values
disabled?: false | string | string[]
}>,
) {
return new Value<string>(async (options) => {
return new Value<keyof Values & string, string>(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<Type> {
*/
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<Values extends Record<string, string>>(
getA: LazyBuild<{
name: string
description?: string | null
warning?: string | null
default: string[]
values: Record<string, string>
values: Values
minLength?: number | null
maxLength?: number | null
disabled?: false | string | string[]
}>,
) {
return new Value<string[]>(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<Type> {
),
* ```
*/
static object<Type extends Record<string, any>>(
static object<
Type extends StaticValidatedAs,
StaticValidatedAs extends Record<string, any>,
>(
a: {
name: string
description?: string | null
},
spec: InputSpec<Type>,
spec: InputSpec<Type, StaticValidatedAs>,
) {
return new Value<Type>(async (options) => {
return new Value<Type, StaticValidatedAs>(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<Type> {
spec: InputSpec<any>
}
},
>(
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<VariantValues>,
) {
return new Value<typeof aVariants.validator._TYPE>(
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<VariantValues>
/**
* @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<Type> {
name: string
description?: string | null
warning?: string | null
variants: Variants<VariantValues>
default: keyof VariantValues & string
disabled: string[] | false | string
}>,
aVariants: Variants<VariantValues>,
) {
return new Value<typeof aVariants.validator._TYPE>(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<UnionRes<VariantValues>, unknown>
static dynamicUnion<
VariantValues extends StaticVariantValues,
StaticVariantValues extends {
[K in string]: {
name: string
spec: InputSpec<any, any>
}
}, aVariants.validator)
},
>(
getA: LazyBuild<{
name: string
description?: string | null
warning?: string | null
variants: Variants<VariantValues>
default: keyof VariantValues & string
disabled: string[] | false | string
}>,
validator: Parser<unknown, UnionResStaticValidatedAs<StaticVariantValues>>,
): Value<
UnionRes<VariantValues>,
UnionResStaticValidatedAs<StaticVariantValues>
>
static dynamicUnion<
VariantValues extends {
[K in string]: {
name: string
spec: InputSpec<any>
}
},
>(
getA: LazyBuild<{
name: string
description?: string | null
warning?: string | null
variants: Variants<VariantValues>
default: keyof VariantValues & string
disabled: string[] | false | string
}>,
validator: Parser<unknown, unknown> = any,
) {
return new Value<UnionRes<VariantValues>, 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<Type> {
hiddenExample: Value.hidden(),
* ```
*/
static hidden<T>(): Value<T, unknown>
static hidden<T>(parser: Parser<unknown, T>): Value<T>
static hidden<T>(parser: Parser<unknown, T> = any) {
return new Value<T>(async () => {
const built: ValueSpecHidden = {
type: "hidden" as const,
return new Value<T, typeof parser._TYPE>(async () => {
return {
spec: {
type: "hidden" as const,
} as ValueSpecHidden,
validator: parser,
}
return built
}, parser)
}
map<U>(fn: (value: Type) => U): Value<U> {
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<T>(getParser: LazyBuild<Parser<unknown, T>>) {
return new Value<T, unknown>(async (options) => {
const validator = await getParser(options)
return {
spec: {
type: "hidden" as const,
} as ValueSpecHidden,
validator,
}
}, any)
}
map<U>(fn: (value: StaticValidatedAs) => U): Value<U> {
return new Value(async (effects) => {
const built = await this.build(effects)
return {
spec: built.spec,
validator: built.validator.map(fn),
}
}, this.validator.map(fn))
}
}

View File

@@ -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<any>
}
},
K extends keyof VariantValues & string = keyof VariantValues & string,
> = {
[key in keyof VariantValues]: {
selection: key
value: ExtractInputSpecStaticValidatedAs<VariantValues[key]["spec"]>
other?: {
[key2 in Exclude<keyof VariantValues & string, key>]?: DeepPartial<
ExtractInputSpecStaticValidatedAs<VariantValues[key2]["spec"]>
>
}
}
}[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<any>
spec: InputSpec<any, any>
}
},
> {
private constructor(
public build: LazyBuild<ValueSpecUnion["variants"]>,
public validator: Parser<unknown, UnionRes<VariantValues>>,
public build: LazyBuild<{
spec: ValueSpecUnion["variants"]
validator: Parser<unknown, UnionRes<VariantValues>>
}>,
public readonly validator: Parser<
unknown,
UnionResStaticValidatedAs<VariantValues>
>,
) {}
readonly _TYPE: UnionRes<VariantValues> = 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<VariantValues[K]["spec"]>
>
}
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<unknown, any>
return new Variants<VariantValues>(async (options) => {
const variants = {} as {
[K in keyof VariantValues]: {
name: string
spec: Record<string, ValueSpec>
).optional()
return new Variants<VariantValues>(
async (options) => {
const validators = {} as {
[K in keyof VariantValues]: Parser<
unknown,
ExtractInputSpecType<VariantValues[K]["spec"]>
>
}
}
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<string, ValueSpec>
}
}
}
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,
)
}
}

View File

@@ -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<InputSpecOf<SmtpValue>>({
export const customSmtp: InputSpec<SmtpValue> = InputSpec.of<
InputSpecOf<SmtpValue>
>({
server: Value.text({
name: "SMTP Server",
required: true,
@@ -42,40 +44,39 @@ export const customSmtp = InputSpec.of<InputSpecOf<SmtpValue>>({
}),
})
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: "<name>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: "<name>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)

View File

@@ -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<string, any> | InputSpec<Record<string, any>>,
> = (options: {
export type Run<A extends Record<string, any>> = (options: {
effects: T.Effects
input: ExtractInputSpecType<A>
input: A
}) => Promise<(T.ActionResult & { version: "1" }) | null | void | undefined>
export type GetInput<
A extends Record<string, any> | InputSpec<Record<string, any>>,
> = (options: {
export type GetInput<A extends Record<string, any>> = (options: {
effects: T.Effects
}) => Promise<null | void | undefined | ExtractPartialInputSpecType<A>>
}) => Promise<null | void | undefined | T.DeepPartial<A>>
export type MaybeFn<T> = T | ((options: { effects: T.Effects }) => Promise<T>)
function callMaybeFn<T>(
@@ -43,39 +37,38 @@ function mapMaybeFn<T, U>(
export interface ActionInfo<
Id extends T.ActionId,
InputSpecType extends Record<string, any> | InputSpec<any>,
Type extends Record<string, any>,
> {
readonly id: Id
readonly _INPUT: InputSpecType
readonly _INPUT: Type
}
export class Action<
Id extends T.ActionId,
InputSpecType extends Record<string, any> | InputSpec<any>,
> implements ActionInfo<Id, InputSpecType>
export class Action<Id extends T.ActionId, Type extends Record<string, any>>
implements ActionInfo<Id, Type>
{
readonly _INPUT: InputSpecType = null as any as InputSpecType
readonly _INPUT: Type = null as any as Type
private cachedParser?: Parser<unknown, Type>
private constructor(
readonly id: Id,
private readonly metadataFn: MaybeFn<T.ActionMetadata>,
private readonly inputSpec: InputSpecType,
private readonly getInputFn: GetInput<InputSpecType>,
private readonly runFn: Run<InputSpecType>,
private readonly inputSpec: InputSpec<Type>,
private readonly getInputFn: GetInput<Type>,
private readonly runFn: Run<Type>,
) {}
static withInput<
Id extends T.ActionId,
InputSpecType extends Record<string, any> | InputSpec<any>,
InputSpecType extends InputSpec<Record<string, any>>,
>(
id: Id,
metadata: MaybeFn<Omit<T.ActionMetadata, "hasInput">>,
inputSpec: InputSpecType,
getInput: GetInput<InputSpecType>,
run: Run<InputSpecType>,
): Action<Id, InputSpecType> {
return new Action(
getInput: GetInput<ExtractInputSpecType<InputSpecType>>,
run: Run<ExtractInputSpecType<InputSpecType>>,
): Action<Id, ExtractInputSpecType<InputSpecType>> {
return new Action<Id, ExtractInputSpecType<InputSpecType>>(
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<T.ActionInput> {
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<InputSpecType>
input: Type
}): Promise<T.ActionResult | null> {
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
)
}
}

View File

@@ -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),