diff --git a/examples/__outputs__/20_output/output_envelope_envelope-error__envelope-error.output.txt b/examples/__outputs__/20_output/output_envelope_envelope-error__envelope-error.output.txt index 2983beaac..16807a090 100644 --- a/examples/__outputs__/20_output/output_envelope_envelope-error__envelope-error.output.txt +++ b/examples/__outputs__/20_output/output_envelope_envelope-error__envelope-error.output.txt @@ -3,7 +3,8 @@ errors: [ ContextualError: There was an error in the interceptor "anonymous" (use named functions to improve this error message) while running hook "encode". at runPipeline (/some/path/to/runPipeline.ts:XX:XX:18) - at async Object.run (/some/path/to/runner.ts:XX:XX:20) + at async (/some/path/to/runner.ts:XX:XX:20) + at async Module.run (/some/path/to/run.ts:XX:XX:10) at async executeDocument (/some/path/to/requestMethods.ts:XX:XX:18) at async executeRootField (/some/path/to/requestMethods.ts:XX:XX:18) at async (/some/path/to/output_envelope_envelope-error__envelope-error.ts:XX:XX:16) { diff --git a/examples/__outputs__/20_output/output_envelope_envelope_error-throw__envelope-error-throw.output.txt b/examples/__outputs__/20_output/output_envelope_envelope_error-throw__envelope-error-throw.output.txt index 63ca338aa..a3ff9bbda 100644 --- a/examples/__outputs__/20_output/output_envelope_envelope_error-throw__envelope-error-throw.output.txt +++ b/examples/__outputs__/20_output/output_envelope_envelope_error-throw__envelope-error-throw.output.txt @@ -5,7 +5,8 @@ ContextualError: There was an error in the interceptor "anonymous" (use named functions to improve this error message) while running hook "encode". at runPipeline (/some/path/to/runPipeline.ts:XX:XX:18) - at async Object.run (/some/path/to/runner.ts:XX:XX:20) + at async (/some/path/to/runner.ts:XX:XX:20) + at async Module.run (/some/path/to/run.ts:XX:XX:10) at async executeDocument (/some/path/to/requestMethods.ts:XX:XX:18) at async executeRootField (/some/path/to/requestMethods.ts:XX:XX:18) at async (/some/path/to/output_envelope_envelope_error-throw__envelope-error-throw.ts:XX:XX:1) { diff --git a/examples/__outputs__/20_output/output_preset__standard-graphql.output.txt b/examples/__outputs__/20_output/output_preset__standard-graphql.output.txt index 245be6938..e0d39046f 100644 --- a/examples/__outputs__/20_output/output_preset__standard-graphql.output.txt +++ b/examples/__outputs__/20_output/output_preset__standard-graphql.output.txt @@ -7,25 +7,26 @@ ContextualError: There was an error in the core implementation of hook "exchange at runPipeline (/some/path/to/runPipeline.ts:XX:XX:18) at async runPipeline (/some/path/to/runPipeline.ts:XX:XX:14) at async runPipeline (/some/path/to/runPipeline.ts:XX:XX:14) - ... 2 lines matching cause stack trace ... + ... 3 lines matching cause stack trace ... at async (/some/path/to/output_preset__standard-graphql.ts:XX:XX:16) { context: { hookName: 'exchange', source: 'implementation' }, [cause]: TypeError: Failed to parse URL from ... at new Request (node:internal/deps/undici/undici:XX:XX) at Object.run (/some/path/to/RequestPipeline.ts:XX:XX:29) ... 6 lines matching cause stack trace ... + at async Object.send (/some/path/to/gql.ts:XX:XX:26) at async (/some/path/to/output_preset__standard-graphql.ts:XX:XX:16) { [cause]: TypeError: Invalid URL at new URL (node:internal/url:XX:XX) at new Request (node:internal/deps/undici/undici:XX:XX) at Object.run (/some/path/to/RequestPipeline.ts:XX:XX:29) - at runHook (/some/path/to/runHook.ts:XX:XX:37) + at runStep (/some/path/to/runStep.ts:XX:XX:37) at runPipeline (/some/path/to/runPipeline.ts:XX:XX:8) at runPipeline (/some/path/to/runPipeline.ts:XX:XX:20) at async runPipeline (/some/path/to/runPipeline.ts:XX:XX:14) - at async Object.run (/some/path/to/runner.ts:XX:XX:20) - at async Object.send (/some/path/to/gql.ts:XX:XX:26) - at async (/some/path/to/output_preset__standard-graphql.ts:XX:XX:16) { + at async (/some/path/to/runner.ts:XX:XX:20) + at async Module.run (/some/path/to/run.ts:XX:XX:10) + at async Object.send (/some/path/to/gql.ts:XX:XX:26) { code: 'ERR_INVALID_URL', input: '...' } diff --git a/examples/__outputs__/20_output/output_return-error.output.txt b/examples/__outputs__/20_output/output_return-error.output.txt index fc41848ac..0cbd2b8cf 100644 --- a/examples/__outputs__/20_output/output_return-error.output.txt +++ b/examples/__outputs__/20_output/output_return-error.output.txt @@ -1,7 +1,8 @@ ---------------------------------------- SHOW ---------------------------------------- ContextualError: There was an error in the interceptor "anonymous" (use named functions to improve this error message) while running hook "encode". at runPipeline (/some/path/to/runPipeline.ts:XX:XX:18) - at async Object.run (/some/path/to/runner.ts:XX:XX:20) + at async (/some/path/to/runner.ts:XX:XX:20) + at async Module.run (/some/path/to/run.ts:XX:XX:10) at async executeDocument (/some/path/to/requestMethods.ts:XX:XX:18) at async executeRootField (/some/path/to/requestMethods.ts:XX:XX:18) at async (/some/path/to/output_return-error.ts:XX:XX:18) { diff --git a/examples/__outputs__/20_output/output_return-error_return-error-execution__return-error-execution.output.txt b/examples/__outputs__/20_output/output_return-error_return-error-execution__return-error-execution.output.txt index 681338192..b46d3a402 100644 --- a/examples/__outputs__/20_output/output_return-error_return-error-execution__return-error-execution.output.txt +++ b/examples/__outputs__/20_output/output_return-error_return-error-execution__return-error-execution.output.txt @@ -23,7 +23,7 @@ ContextualAggregateError: One or more errors in the execution result. ] at (/some/path/to/handleOutput.ts:XX:XX:16) at Array.map () - at handleOutput (/some/path/to/handleOutput.ts:XX:XX:21) + at handleOutput (/some/path/to/handleOutput.ts:XX:XX:27) at executeDocument (/some/path/to/requestMethods.ts:XX:XX:10) at process.processTicksAndRejections (node:internal/process/task_queues:XX:XX) at async executeRootField (/some/path/to/requestMethods.ts:XX:XX:18) @@ -37,7 +37,8 @@ ContextualAggregateError: One or more errors in the execution result. ContextualError: There was an error in the interceptor "anonymous" (use named functions to improve this error message) while running hook "encode". at runPipeline (/some/path/to/runPipeline.ts:XX:XX:18) at process.processTicksAndRejections (node:internal/process/task_queues:XX:XX) - at async Object.run (/some/path/to/runner.ts:XX:XX:20) + at async (/some/path/to/runner.ts:XX:XX:20) + at async Module.run (/some/path/to/run.ts:XX:XX:10) at async executeDocument (/some/path/to/requestMethods.ts:XX:XX:18) at async executeRootField (/some/path/to/requestMethods.ts:XX:XX:18) at async (/some/path/to/output_return-error_return-error-execution__return-error-execution.ts:XX:XX:3) { diff --git a/src/ClientPreset/ClientPreset.ts b/src/ClientPreset/ClientPreset.ts index b562b1922..bf7710e23 100644 --- a/src/ClientPreset/ClientPreset.ts +++ b/src/ClientPreset/ClientPreset.ts @@ -14,7 +14,7 @@ import type { } from '../extension/extension.js' import type { Builder } from '../lib/builder/__.js' import type { ConfigManager } from '../lib/config-manager/__.js' -import { type mergeArrayOfObjects, type ToParametersExact } from '../lib/prelude.js' +import { type ToParametersExact, type Tuple } from '../lib/prelude.js' import type { GlobalRegistry } from '../types/GlobalRegistry/GlobalRegistry.js' import { Schema } from '../types/Schema/__.js' import type { SchemaDrivenDataMap } from '../types/SchemaDrivenDataMap/__.js' @@ -101,7 +101,7 @@ type ConstructorParameters< $Extensions extends [...ExtensionConstructor[]], > = & InputBase> - & mergeArrayOfObjects> + & Tuple.IntersectItems> // dprint-ignore type GetParametersContributedByExtensions = { diff --git a/src/client/Settings/client.create.config.output.test-d.ts b/src/client/Settings/client.create.config.output.test-d.ts index d5c4b038e..3b10c883a 100644 --- a/src/client/Settings/client.create.config.output.test-d.ts +++ b/src/client/Settings/client.create.config.output.test-d.ts @@ -6,7 +6,7 @@ import { Graffle } from '../../../tests/_/schemas/kitchen-sink/graffle/__.js' import { schema } from '../../../tests/_/schemas/kitchen-sink/schema.js' import { assertEqual } from '../../lib/assert-equal.js' import { type GraphQLExecutionResultError } from '../../lib/grafaid/graphql.js' -import type { ErrorsOther } from '../handleOutput.js' +import type { requestPipeline } from '../../requestPipeline/RequestPipeline.js' const G = Graffle.create @@ -99,7 +99,7 @@ describe('.envelope', () => { describe('.errors', () => { test('defaults to execution errors in envelope', () => { const g = G({ schema, output: { defaults: { errorChannel: 'return' }, envelope: true } }) - expectTypeOf(g.query.__typename()).resolves.toMatchTypeOf | ErrorsOther>() + expectTypeOf(g.query.__typename()).resolves.toMatchTypeOf | requestPipeline.ResultFailure>() }) test('.execution:false restores errors to return', async () => { const g = G({ @@ -107,7 +107,7 @@ describe('.envelope', () => { output: { defaults: { errorChannel: 'return' }, envelope: { errors: { execution: false } } }, }) expectTypeOf(await g.query.__typename()).toEqualTypeOf< - Omit, 'errors'> | ErrorsOther | GraphQLExecutionResultError + Omit, 'errors'> | requestPipeline.ResultFailure | GraphQLExecutionResultError >() }) test('.other:true raises them to envelope', () => { @@ -135,7 +135,9 @@ describe('defaults.errorChannel: "return"', () => { describe('puts errors into return type', () => { const g = G({ schema, output: { defaults: { errorChannel: 'return' } } }) test('query.', async () => { - expectTypeOf(await g.query.__typename()).toEqualTypeOf<'Query' | ErrorsOther | GraphQLExecutionResultError>() + expectTypeOf(await g.query.__typename()).toEqualTypeOf< + 'Query' | requestPipeline.ResultFailure | GraphQLExecutionResultError + >() }) }) describe('with .errors', () => { @@ -144,7 +146,7 @@ describe('defaults.errorChannel: "return"', () => { schema, output: { defaults: { errorChannel: 'return' }, errors: { execution: 'throw' } }, }) - expectTypeOf(await g.query.__typename()).toEqualTypeOf<'Query' | ErrorsOther>() + expectTypeOf(await g.query.__typename()).toEqualTypeOf<'Query' | requestPipeline.ResultFailure>() }) test('.other: throw', async () => { const g = G({ diff --git a/src/client/builderExtensions/anyware.ts b/src/client/builderExtensions/anyware.ts index c5efceeb7..a256a93b3 100644 --- a/src/client/builderExtensions/anyware.ts +++ b/src/client/builderExtensions/anyware.ts @@ -1,7 +1,7 @@ import { createExtension } from '../../extension/extension.js' -import type { Anyware, Anyware as AnywareLib } from '../../lib/anyware/__.js' +import type { Anyware as AnywareLib } from '../../lib/anyware/__.js' import { Builder } from '../../lib/builder/__.js' -import type { RequestPipeline } from '../../requestPipeline/__.js' +import type { requestPipeline } from '../../requestPipeline/__.js' import { type Context } from '../context.js' export interface BuilderExtensionAnyware extends Builder.Extension { @@ -15,13 +15,15 @@ export interface Anyware<$Arguments extends Builder.Extension.Parameters>, + interceptor: AnywareLib.Interceptor.InferConstructor< + requestPipeline.Spec<$Arguments['context']['config']> + >, ) => Builder.Definition.MaterializeWithNewContext<$Arguments['chain'], $Arguments['context']> } export const builderExtensionAnyware = Builder.Extension.create((builder, context) => { const properties = { - anyware: (interceptor: Anyware.Interceptor) => { + anyware: (interceptor: AnywareLib.Interceptor.InferConstructor) => { return builder({ ...context, extensions: [ diff --git a/src/client/builderExtensions/scalar.test-d.ts b/src/client/builderExtensions/scalar.test-d.ts index f5e2e069e..08051dda3 100644 --- a/src/client/builderExtensions/scalar.test-d.ts +++ b/src/client/builderExtensions/scalar.test-d.ts @@ -2,7 +2,7 @@ import { DateScalar, FooScalar } from '../../../tests/_/fixtures/scalars.js' import { schemaMap } from '../../../tests/_/schemas/kitchen-sink/graffle/__.js' import { Graffle } from '../../entrypoints/__Graffle.js' import { assertEqual } from '../../lib/assert-equal.js' -import { any, type SomeFunction } from '../../lib/prelude.js' +import { _, type SomeFunction } from '../../lib/prelude.js' import type { TypeErrorMissingSchemaMap } from './scalar.js' const g1 = Graffle.create({ schema: `foo` }) @@ -12,9 +12,9 @@ const g2 = Graffle.create({ schema: `foo`, schemaMap }) assertEqual() // @ts-expect-error "Foo" is not a scalar name in the schema. -Graffle.create({ schema: `foo`, schemaMap }).scalar(`Foo`, any) +Graffle.create({ schema: `foo`, schemaMap }).scalar(`Foo`, _) // @ts-expect-error "Foo" is not a scalar name in the schema. Graffle.create({ schema: `foo`, schemaMap }).scalar(FooScalar) -Graffle.create({ schema: `foo`, schemaMap }).scalar(`Date`, any) +Graffle.create({ schema: `foo`, schemaMap }).scalar(`Date`, _) Graffle.create({ schema: `foo`, schemaMap }).scalar(DateScalar) -Graffle.create({ schema: `foo`, schemaMap }).scalar(`Int`, any) +Graffle.create({ schema: `foo`, schemaMap }).scalar(`Int`, _) diff --git a/src/client/client.transport-http.test.ts b/src/client/client.transport-http.test.ts index 733465b5f..925990a4d 100644 --- a/src/client/client.transport-http.test.ts +++ b/src/client/client.transport-http.test.ts @@ -5,20 +5,22 @@ import { Graffle as Pokemon } from '../../tests/_/schemas/pokemon/graffle/__.js' import { schema as schemaPokemon } from '../../tests/_/schemas/pokemon/schema.js' import { Graffle } from '../entrypoints/main.js' import { ACCEPT_REC, CONTENT_TYPE_REC } from '../lib/grafaid/http/http.js' -import type { CoreExchangeGetRequest, CoreExchangePostRequest } from '../requestPipeline/types.js' -import { Transport } from '../types/Transport.js' +import type { requestPipeline } from '../requestPipeline/__.js' +import { Transport, type TransportHttp } from '../types/Transport.js' const schema = new URL(`https://foo.io/api/graphql`) test(`anyware hooks are typed to http transport`, () => { Graffle.create({ schema }).anyware(async ({ encode }) => { - expectTypeOf(encode.input.transportType).toEqualTypeOf(Transport.http) + expectTypeOf(encode.input.transportType).toEqualTypeOf() const { pack } = await encode() expectTypeOf(pack.input.transportType).toEqualTypeOf(Transport.http) const { exchange } = await pack() expectTypeOf(exchange.input.transportType).toEqualTypeOf(Transport.http) // todo we can statically track the method mode like we do the transport mode - expectTypeOf(exchange.input.request).toEqualTypeOf() + expectTypeOf(exchange.input.request).toEqualTypeOf< + requestPipeline.Steps.CoreExchangePostRequest | requestPipeline.Steps.CoreExchangeGetRequest + >() const { unpack } = await exchange() expectTypeOf(unpack.input.transportType).toEqualTypeOf(Transport.http) expectTypeOf(unpack.input.response).toEqualTypeOf() diff --git a/src/client/gql/gql.ts b/src/client/gql/gql.ts index e81c220a7..dc3991101 100644 --- a/src/client/gql/gql.ts +++ b/src/client/gql/gql.ts @@ -1,3 +1,4 @@ +import { Anyware } from '../../lib/anyware/__.js' import { Builder } from '../../lib/builder/__.js' import type { Grafaid } from '../../lib/grafaid/__.js' import { getOperationType } from '../../lib/grafaid/document.js' @@ -6,10 +7,9 @@ import { joinTemplateStringArrayAndArgs, type TemplateStringsArguments, } from '../../lib/template-string.js' -import { RequestPipeline } from '../../requestPipeline/__.js' // todo +import { requestPipeline } from '../../requestPipeline/__.js' // todo import { type Context } from '../context.js' import { handleOutput } from '../handleOutput.js' -import type { Config } from '../Settings/Config.js' import { type DocumentController, resolveSendArguments, type sendArgumentsImplementation } from './send.js' export interface BuilderExtensionGql extends Builder.Extension { @@ -71,9 +71,9 @@ export const builderExtensionGql = Builder.Extension.create schema, // request, request: analyzedRequest, - } as RequestPipeline.Hooks.HookDefEncode['input'] + } as requestPipeline.Steps.HookDefEncode['input'] - const result = await RequestPipeline.RequestPipeline.run({ + const result = await Anyware.Pipeline.run(requestPipeline, { initialInput, // retryingExtension: context.retry as any, interceptors: context.extensions.filter(_ => _.onRequest !== undefined).map(_ => _.onRequest!) as any, diff --git a/src/client/handleOutput.ts b/src/client/handleOutput.ts index d0d522194..076f1248d 100644 --- a/src/client/handleOutput.ts +++ b/src/client/handleOutput.ts @@ -11,6 +11,7 @@ import { type GetOrNever, type Values, } from '../lib/prelude.js' +import type { requestPipeline } from '../requestPipeline/RequestPipeline.js' import type { GlobalRegistry } from '../types/GlobalRegistry/GlobalRegistry.js' import type { TransportHttp } from '../types/Transport.js' import type { Context } from './context.js' @@ -22,16 +23,7 @@ import { readConfigErrorCategoryOutputChannel, } from './Settings/Config.js' -/** - * Types of "other" Graffle Error. - */ -export type ErrorsOther = - | Errors.ContextualError - // Possible from http transport fetch with abort controller. - | DOMException - export type GraffleExecutionResultEnvelope<$Config extends Config = Config> = - // & ExecutionResult & { errors?: ReadonlyArray< // formatted comes from http transport @@ -56,17 +48,13 @@ export type GraffleExecutionResultEnvelope<$Config extends Config = Config> = } : {}) -export type GraffleExecutionResultVar<$Config extends Config = Config> = - | GraffleExecutionResultEnvelope<$Config> - | ErrorsOther - export const handleOutput = ( state: Context, - result: GraffleExecutionResultVar, + result: requestPipeline.Result, ) => { if (isContextConfigTraditionalGraphQLOutput(state.config)) { if (result instanceof Error) throw result - return result + return result.value } const config = state.config @@ -93,11 +81,11 @@ export const handleOutput = ( return isEnvelope ? { errors: [result] } : result } - if (result.errors && result.errors.length > 0) { + if (result.value.errors && result.value.errors.length > 0) { const error = new Errors.ContextualAggregateError( `One or more errors in the execution result.`, {}, - result.errors.map(e => { + result.value.errors.map(e => { if (e instanceof Error) return e const { message, ...context } = e return new Errors.ContextualError(message, context) @@ -105,14 +93,14 @@ export const handleOutput = ( ) if (isThrowExecution) throw error if (isReturnExecution) return error - return isEnvelope ? { ...result, errors: [...result.errors ?? [], error] } : error + return isEnvelope ? { ...result.value, errors: [...result.value.errors ?? [], error] } : error } if (isEnvelope) { - return result + return result.value } - return result.data + return result.value.data } /** @@ -156,49 +144,10 @@ type HandleOutput_Envelope<$Context extends Context, $Envelope extends GraffleEx ? $Envelope : ExcludeUndefined<$Envelope['data']> // todo make data field not undefinable -// type HandleOutputGql_Envelope - -// | IfConfiguredGetOutputErrorReturns<$Config> -// | ( -// $Config['output']['envelope']['enabled'] extends true -// // todo even when envelope is enabled, its possible errors can not be included in its output. -// // When not, undefined should be removed from the data property. -// ? Envelope<$Config, $Data> -// // Note 1 -// // `undefined` is not a possible type because that would only happen if an error occurred. -// // If an error occurs when the envelope is disabled then either it throws or is returned. -// // No case swallows the error and returns undefined data. -// // -// // Note 2 -// // null is possible because of GraphQL null propagation. -// // todo We need to integrate this reality into the the other typed non-envelope output types too. -// : $Data | null -// ) - -// // dprint-ignore -// export type HandleOutputGraffleRootType<$Config extends Config, $Data> = -// | IfConfiguredGetOutputErrorReturns<$Config> -// | ( -// $Config['output']['envelope']['enabled'] extends true -// ? Envelope<$Config, $Data> -// : $Data -// ) - -// // dprint-ignore -// export type HandleOutputGraffleRootField<$Config extends Config, $RootFieldName extends string, $Data> = -// | IfConfiguredGetOutputErrorReturns<$Config> -// | ( -// $Config['output']['envelope']['enabled'] extends true -// // todo: a typed execution result that allows for additional error types. -// // currently it is always graphql execution error however envelope configuration can put more errors into that. -// ? Envelope<$Config, { [_ in $RootFieldName]: $Data }> -// : $Data -// ) - // dprint-ignore type IfConfiguredGetOutputErrorReturns<$Context extends Context> = - | (ConfigGetOutputError<$Context, 'execution'> extends 'return' ? GraphQLExecutionResultError : never) - | (ConfigGetOutputError<$Context, 'other'> extends 'return' ? ErrorsOther : never) + | (ConfigGetOutputError<$Context, 'execution'> extends 'return' ? GraphQLExecutionResultError : never) + | (ConfigGetOutputError<$Context, 'other'> extends 'return' ? requestPipeline.ResultFailure : never) // dprint-ignore export type ConfigGetOutputError<$Context extends Context, $ErrorCategory extends ErrorCategory> = diff --git a/src/documentBuilder/InferResult/Alias.ts b/src/documentBuilder/InferResult/Alias.ts index 029f6c7b4..ad214e2e8 100644 --- a/src/documentBuilder/InferResult/Alias.ts +++ b/src/documentBuilder/InferResult/Alias.ts @@ -1,4 +1,4 @@ -import type { mergeArrayOfObjects, ValuesOrEmptyObject } from '../../lib/prelude.js' +import type { Tuple, ValuesOrEmptyObject } from '../../lib/prelude.js' import type { Schema } from '../../types/Schema/__.js' import type { Select } from '../Select/__.js' import type { OutputField } from './OutputField.js' @@ -42,7 +42,7 @@ type InferSelectAliasMultiple< $FieldName extends string, $Schema extends Schema, $Node extends Schema.OutputObject, -> = mergeArrayOfObjects< +> = Tuple.IntersectItems< { [_ in keyof $SelectAliasMultiple]: InferSelectAliasOne<$SelectAliasMultiple[_], $FieldName, $Schema, $Node> } diff --git a/src/documentBuilder/requestMethods/requestMethods.ts b/src/documentBuilder/requestMethods/requestMethods.ts index 13205bffc..d181baa38 100644 --- a/src/documentBuilder/requestMethods/requestMethods.ts +++ b/src/documentBuilder/requestMethods/requestMethods.ts @@ -2,13 +2,13 @@ import { OperationTypeNode } from 'graphql' import type { SimplifyDeep } from 'type-fest' import { type Context } from '../../client/context.js' import { handleOutput } from '../../client/handleOutput.js' -import type { Config } from '../../client/Settings/Config.js' import type { TypeFunction } from '../../entrypoints/utilities-for-generated.js' +import { Anyware } from '../../lib/anyware/__.js' import { Builder } from '../../lib/builder/__.js' import type { Grafaid } from '../../lib/grafaid/__.js' import { getOperationDefinition } from '../../lib/grafaid/document.js' import { isSymbol } from '../../lib/prelude.js' -import { RequestPipeline } from '../../requestPipeline/__.js' +import { requestPipeline } from '../../requestPipeline/__.js' import type { GlobalRegistry } from '../../types/GlobalRegistry/GlobalRegistry.js' import { Select } from '../Select/__.js' import { SelectionSetGraphqlMapper } from '../SelectGraphQLMapper/__.js' @@ -135,9 +135,9 @@ const executeDocument = async ( url, schema, request, - } as RequestPipeline.Hooks.HookDefEncode['input'] + } as requestPipeline.Steps.HookDefEncode['input'] - const result = await RequestPipeline.RequestPipeline.run({ + const result = await Anyware.Pipeline.run(requestPipeline, { initialInput, // retryingExtension: state.retry as any, interceptors: state.extensions.filter(_ => _.onRequest !== undefined).map(_ => _.onRequest!) as any, diff --git a/src/extension/extension.ts b/src/extension/extension.ts index 5b5320229..0b9813b89 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -8,7 +8,7 @@ import type { Builder } from '../lib/builder/__.js' import type { AssertExtends } from '../lib/prelude.js' import type { TypeFunction } from '../lib/type-function/__.js' import type { Fn } from '../lib/type-function/TypeFunction.js' -import type { RequestPipeline } from '../requestPipeline/__.js' +import type { requestPipeline } from '../requestPipeline/__.js' import type { GlobalRegistry } from '../types/GlobalRegistry/GlobalRegistry.js' export interface TypeHooks { @@ -57,7 +57,7 @@ export interface Extension< /** * Anyware executed on every request. */ - onRequest?: Anyware.Interceptor + onRequest?: Anyware.Interceptor.InferConstructor /** * Manipulate the builder. * You can extend the builder with new properties at both runtime AND buildtime (types, TypeScript). @@ -169,7 +169,7 @@ export const createExtension = < custom?: $Custom create: (params: { config: $Config }) => { builder?: $BuilderExtension - onRequest?: Anyware.Interceptor + onRequest?: Anyware.Interceptor.InferConstructor typeHooks?: () => $TypeHooks } }, diff --git a/src/extensions/Upload/Upload.ts b/src/extensions/Upload/Upload.ts index 1b50f23b1..c16c7f3e0 100644 --- a/src/extensions/Upload/Upload.ts +++ b/src/extensions/Upload/Upload.ts @@ -20,7 +20,6 @@ export const Upload = createExtension({ // @see https://stackoverflow.com/questions/3508338/what-is-the-boundary-in-multipart-form-data // @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition return await pack({ - // todo rename "using" to "with" using: { body: (input) => { const hasUploadScalarVariable = input.variables && isUsingUploadScalar(input.variables) diff --git a/src/lib/anyware/ExecutableStep.ts b/src/lib/anyware/ExecutableStep.ts new file mode 100644 index 000000000..b3aa5f91c --- /dev/null +++ b/src/lib/anyware/ExecutableStep.ts @@ -0,0 +1,7 @@ +import type { Step } from './Step.js' + +export interface ExecutableStep extends Step { + run: (params: any) => any +} + +export interface ExecutableStepRuntime extends Omit {} diff --git a/src/lib/anyware/Interceptor.ts b/src/lib/anyware/Interceptor.ts deleted file mode 100644 index 33a84c0d8..000000000 --- a/src/lib/anyware/Interceptor.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Deferred, MaybePromise } from '../prelude.js' -import type { Private } from '../private.js' -import type { InferPublicHooks, SomePublicHookEnvelope } from './hook/public.js' -import type { Pipeline } from './Pipeline/Pipeline.js' - -export type InterceptorOptions = { - retrying: boolean -} - -export type Interceptor< - $Core extends Pipeline = Pipeline, - $Options extends InterceptorOptions = InterceptorOptions, -> = ( - hooks: InferPublicHooks< - Private.Get<$Core>['hookSequence'], - Private.Get<$Core>['hookMap'], - Private.Get<$Core>['result'], - $Options - >, -) => Promise< - | Private.Get<$Core>['result'] - | SomePublicHookEnvelope -> - -export type InterceptorGeneric = NonRetryingInterceptor | RetryingInterceptor - -export type NonRetryingInterceptor = { - retrying: false - name: string - entrypoint: string - body: Deferred - currentChunk: Deferred -} - -export type RetryingInterceptor = { - retrying: true - name: string - entrypoint: string - body: Deferred - currentChunk: Deferred -} - -export const createRetryingInterceptor = (extension: NonRetryingInterceptorInput): RetryingInterceptorInput => { - return { - retrying: true, - run: extension, - } -} - -// export type ExtensionInput<$Input extends object = object> = (input: $Input) => MaybePromise -export type InterceptorInput<$Input extends object = any> = - | NonRetryingInterceptorInput<$Input> - | RetryingInterceptorInput<$Input> - -export type NonRetryingInterceptorInput<$Input extends object = any> = ( - input: $Input, -) => MaybePromise - -export type RetryingInterceptorInput<$Input extends object = any> = { - retrying: boolean - run: (input: $Input) => MaybePromise -} diff --git a/src/lib/anyware/Interceptor/Interceptor.test-d.ts b/src/lib/anyware/Interceptor/Interceptor.test-d.ts new file mode 100644 index 000000000..da3b84d21 --- /dev/null +++ b/src/lib/anyware/Interceptor/Interceptor.test-d.ts @@ -0,0 +1,97 @@ +import { describe, expectTypeOf, test } from 'vitest' +import { _, type ExcludeUndefined } from '../../prelude.js' +import type { Interceptor } from '../_.js' +import { Pipeline } from '../_.js' +import type { initialInput } from '../__.test-helpers.js' +import { results, slots } from '../__.test-helpers.js' +import type { StepTriggerEnvelope } from '../StepTriggerEnvelope.js' + +const b0 = Pipeline.create() + +describe(`interceptor constructor`, () => { + test(`receives keyword arguments, a step trigger for each step`, () => { + const p1 = b0 + .step({ name: `a`, run: () => results.a }) + .step({ name: `b`, run: () => results.b }) + .step({ name: `c`, run: () => results.c }) + .done() + type i1 = Interceptor.InferConstructor + expectTypeOf>().toMatchTypeOf<[steps: { a: any; b: any; c: any }]>() + expectTypeOf>().toMatchTypeOf<[steps: { + a: (params: { input?: initialInput }) => Promise<{ b: (params: { input?: results['a'] }) => any }> + b: (params: { input?: results['a'] }) => Promise<{ c: (params: { input?: results['b'] }) => any }> + c: (params: { input?: results['b'] }) => Promise + }]>() + }) + + // --- trigger --- + + test(`original input on self`, () => { + const p = b0.step({ name: `a`, run: () => results.a }).done() + type triggerA = GetTriggerFromPipeline + expectTypeOf().toMatchTypeOf() + }) + + test(`trigger arguments are optional`, () => { + const p = b0.step({ name: `a`, run: () => results.a }).done() + type triggerA = GetTriggerFromPipeline + expectTypeOf<[]>().toMatchTypeOf>() + }) + + // --- slots --- + + test(`trigger accepts slots if definition has them, otherwise does NOT so much as accept the slots key`, () => { + const p = b0.step({ name: `a`, slots, run: () => results.a }).step({ name: `b`, run: () => results.b }).done() + type triggerA = GetTriggerFromPipeline + type triggerB = GetTriggerFromPipeline + expectTypeOf>().toEqualTypeOf<[params?: { + input?: initialInput + using?: { + m?: () => Promise<'m' | undefined> + n?: () => 'n' | undefined + } + }]> + expectTypeOf>().toEqualTypeOf<[params?: { input?: results['a'] }]> // no "using" key! + }) + + test(`slots are optional`, () => { + const p = b0.step({ name: `a`, slots, run: () => results.a }).done() + type triggerA = GetTriggerFromPipeline + type triggerASlotInputs = ExcludeUndefined[0]>['using']> + expectTypeOf<{ m?: any; n?: any }>().toMatchTypeOf() + }) + + test(`slot function can return undefined (falls back to default slot)`, () => { + const p = b0.step({ name: `a`, slots, run: () => results.a }).done() + type triggerA = GetTriggerFromPipeline + type triggerASlotMOutput = ReturnType< + ExcludeUndefined[0]>['using']>['m']> + > + expectTypeOf>().toEqualTypeOf() + type triggerASlotNOutput = ReturnType< + ExcludeUndefined[0]>['using']>['n']> + > + expectTypeOf<`n` | undefined>().toEqualTypeOf() + }) + + // --- output --- + // + test(`can return pipeline output or a step envelope`, () => { + const p = b0.step({ name: `a`, run: () => results.a }).done() + type i = GetReturnTypeFromPipeline + expectTypeOf().toEqualTypeOf>() + }) + + test(`return type awaits pipeline output`, () => { + const p = b0.step({ name: `a`, run: () => Promise.resolve(results.a) }).done() + expectTypeOf>().toEqualTypeOf>() + }) +}) + +// --- Helpers --- + +// dprint-ignore +// @ts-expect-error +type GetTriggerFromPipeline<$Pipeline extends Pipeline.PipelineExecutable, $TriggerName extends string> = Parameters>[0][$TriggerName] +// dprint-ignore +type GetReturnTypeFromPipeline<$Pipeline extends Pipeline.PipelineExecutable> = ReturnType> diff --git a/src/lib/anyware/Interceptor/Interceptor.ts b/src/lib/anyware/Interceptor/Interceptor.ts new file mode 100644 index 000000000..c410ff3a2 --- /dev/null +++ b/src/lib/anyware/Interceptor/Interceptor.ts @@ -0,0 +1,87 @@ +import type { Simplify } from 'type-fest' +import type { Deferred, MaybePromise } from '../../prelude.js' +import type { PipelineSpec } from '../_.js' +import type { ResultSuccess } from '../Pipeline/Result.js' +import type { Step } from '../Step.js' +import type { StepTrigger } from '../StepTrigger.js' +import type { StepTriggerEnvelope } from '../StepTriggerEnvelope.js' + +export type InterceptorOptions = { + retrying: boolean +} + +export namespace Interceptor { + export interface InferConstructor< + $PipelineSpec extends PipelineSpec = PipelineSpec, + > // $Options extends InterceptorOptions = InterceptorOptions, + { + ( + steps: Simplify>, + ): Promise< + | $PipelineSpec['output'] + | StepTriggerEnvelope + > + } + + type InferConstructorKeywordArguments< + $PipelineSpec extends PipelineSpec, + > = InferConstructorKeywordArguments_<$PipelineSpec['steps'], Awaited<$PipelineSpec['output']>> + + // dprint-ignore + type InferConstructorKeywordArguments_< + $Steps extends Step[], + $PipelineOutput, + > = + $Steps extends [infer $NextStep extends Step, ...infer $NextNextSteps extends Step[]] + ? & { + [_ in $NextStep['name']]: StepTrigger.Infer<$NextStep, $NextNextSteps, $PipelineOutput> + } + & InferConstructorKeywordArguments_<$NextNextSteps, $PipelineOutput> + : {} +} + +export type InterceptorGeneric = NonRetryingInterceptor | RetryingInterceptor + +export type NonRetryingInterceptor = { + retrying: false + name: string + entrypoint: string + body: Deferred + currentChunk: Deferred +} + +export type RetryingInterceptor = { + retrying: true + name: string + entrypoint: string + body: Deferred + currentChunk: Deferred +} + +export const createRetryingInterceptor = (interceptor: NonRetryingInterceptorInput): RetryingInterceptorInput => { + return { + retrying: true, + run: interceptor, + } +} + +// export interface InterceptorInput { +// name: string +// // entrypoint: string +// // body: Deferred +// // currentChunk: Deferred +// } + +// export type ExtensionInput<$Input extends object = object> = (input: $Input) => MaybePromise +export type InterceptorInput<$Input extends object = any> = + | NonRetryingInterceptorInput<$Input> + | RetryingInterceptorInput<$Input> + +export type NonRetryingInterceptorInput<$Input extends object = any> = ( + input: $Input, +) => MaybePromise + +export type RetryingInterceptorInput<$Input extends object = any> = { + retrying: boolean + run: (input: $Input) => MaybePromise +} diff --git a/src/lib/anyware/Pipeline/Config.ts b/src/lib/anyware/Pipeline/Config.ts new file mode 100644 index 000000000..1cc9f1884 --- /dev/null +++ b/src/lib/anyware/Pipeline/Config.ts @@ -0,0 +1,34 @@ +import type { StepResultError } from '../StepResult.js' + +export interface Options { + /** + * @defaultValue `required` + */ + entrypointSelectionMode?: 'optional' | 'required' | 'off' + /** + * If a hook results in a thrown error but is an instance of one of these classes then return it as-is + * rather than wrapping it in a ContextualError. + * + * This can be useful when there are known kinds of errors such as Abort Errors from AbortController + * which are actually a signaling mechanism. + */ + passthroughErrorInstanceOf?: Function[] + /** + * If a hook results in a thrown error but returns true from this function then return the error as-is + * rather than wrapping it in a ContextualError. + * + * This can be useful when there are known kinds of errors such as Abort Errors from AbortController + * which are actually a signaling mechanism. + */ + passthroughErrorWith?: null | ((signal: StepResultError) => boolean) +} + +export type Config = Required + +export const resolveOptions = (options?: Options): Config => { + return { + passthroughErrorInstanceOf: options?.passthroughErrorInstanceOf ?? [], + passthroughErrorWith: options?.passthroughErrorWith ?? null, + entrypointSelectionMode: options?.entrypointSelectionMode ?? `required`, + } +} diff --git a/src/lib/anyware/Pipeline/Executable.ts b/src/lib/anyware/Pipeline/Executable.ts new file mode 100644 index 000000000..8e6245179 --- /dev/null +++ b/src/lib/anyware/Pipeline/Executable.ts @@ -0,0 +1,14 @@ +import type { ExecutableStep, ExecutableStepRuntime } from '../ExecutableStep.js' +import type { Step } from '../Step.js' +import type { Config } from './Config.js' +import type { PipelineSpec } from './Spec.js' + +// todo - spec as a property, not extended +// todo - output type NOT spec but final type +export interface PipelineExecutable extends PipelineSpec { + config: Config + steps: ExecutableStep[] + stepsIndex: StepsIndex +} + +export type StepsIndex = Map diff --git a/src/lib/anyware/Pipeline/Pipeline.ts b/src/lib/anyware/Pipeline/Pipeline.ts deleted file mode 100644 index 8a789748a..000000000 --- a/src/lib/anyware/Pipeline/Pipeline.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { FindValueAfter, IsLastValue } from '../../prelude.js' -import type { Private } from '../../private.js' -import type { HookDefinitionMap, HookSequence } from '../hook/definition.js' -import type { HookPrivateInput, HookResultError, PrivateHook } from '../hook/private.js' -export * as Pipeline from './builder.js' - -// dprint-ignore -export type Pipeline< - $HookSequence extends HookSequence = HookSequence, - $HookMap extends HookDefinitionMap<$HookSequence> = HookDefinitionMap<$HookSequence>, - $Result = unknown, -> = - // todo what is the point of private here? Its usually for hiding fields on a user facing interface. But is this type actually user facing? - Private.Add< - { - hookSequence: $HookSequence - hookMap: $HookMap - result: $Result - }, - { - hookNamesOrderedBySequence: $HookSequence - hooks: { - [$HookName in $HookSequence[number]]: - PrivateHook< - $HookMap[$HookName]['slots'], - HookPrivateInput< - $HookMap[$HookName]['input'], - $HookMap[$HookName]['slots'] - >, - IsLastValue<$HookName, $HookSequence> extends true - ? $Result - : $HookMap[FindValueAfter<$HookName, $HookSequence>] - > - } - passthroughErrorInstanceOf?: Function[] - passthroughErrorWith?: (signal: HookResultError) => boolean - } - > diff --git a/src/lib/anyware/Pipeline/Result.ts b/src/lib/anyware/Pipeline/Result.ts new file mode 100644 index 000000000..6f56f5f8e --- /dev/null +++ b/src/lib/anyware/Pipeline/Result.ts @@ -0,0 +1,14 @@ +import type { Errors } from '../../errors/__.js' +import type { PipelineSpec } from './Spec.js' + +export type InferResultFromSpec<$PipelineSpec extends PipelineSpec> = Result<$PipelineSpec['output']> + +export type ResultFailure = Errors.ContextualAggregateError + +export type Result = ResultFailure | ResultSuccess + +export interface ResultSuccess { + value: T +} + +export const successfulResult = <$Value>(value: $Value): ResultSuccess<$Value> => ({ value }) diff --git a/src/lib/anyware/Pipeline/Spec.test-d.ts b/src/lib/anyware/Pipeline/Spec.test-d.ts new file mode 100644 index 000000000..5567a7b19 --- /dev/null +++ b/src/lib/anyware/Pipeline/Spec.test-d.ts @@ -0,0 +1,48 @@ +import { assertEqual } from '../../assert-equal.js' +import type { MaybePromise } from '../../prelude.js' +import type { PipelineSpecFromSteps } from './Spec.js' + +assertEqual< + PipelineSpecFromSteps<[]>, + { steps: []; input: object; output: unknown } +>() + +assertEqual< + PipelineSpecFromSteps<[{ name: 'a' }]>, + { steps: [{ name: 'a'; slots: undefined; input: object; output: unknown }]; input: object; output: unknown } +>() + +assertEqual< + PipelineSpecFromSteps<[{ name: 'a'; output: 1 }]>, + { + steps: [{ name: 'a'; slots: undefined; input: object; output: MaybePromise<1> }] + input: object + output: 1 + // ^^^^^^ pipeline output inferred from last step output + } +>() + +assertEqual< + PipelineSpecFromSteps<[{ name: 'a' }, { name: 'b'; input: { x: 1 } }]>, + { + steps: [ + { name: 'a'; slots: undefined; input: object; output: MaybePromise<{ x: 1 }> }, + // step output inferred from next step input ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + { name: 'b'; slots: undefined; input: { x: 1 }; output: unknown }, + ] + input: object + output: unknown + } +>() + +assertEqual< + PipelineSpecFromSteps<[{ name: 'a'; output: Promise<1> }]>, + // ^^^^^^^^^^^^^^^^^^ Step promised output flattened within MaybePromise + { + steps: [{ name: 'a'; slots: undefined; input: object; output: MaybePromise<1> }] + // ^^^^^^^^^^^^^^^ + input: object + output: 1 + // ^^^^^^^^^ Pipeline output strips Promise type + } +>() diff --git a/src/lib/anyware/Pipeline/Spec.ts b/src/lib/anyware/Pipeline/Spec.ts new file mode 100644 index 000000000..25c4dc9cb --- /dev/null +++ b/src/lib/anyware/Pipeline/Spec.ts @@ -0,0 +1,51 @@ +import type { IsUnknown } from 'type-fest' +import type { MaybePromise, Tuple } from '../../prelude.js' +import type { Step } from '../Step.js' + +// dprint-ignore +export type PipelineSpecFromSteps<$StepSpecInputs extends Step.SpecInput[] = Step.SpecInput[]> = PipelineSpec> + +// dprint-ignore +export interface PipelineSpec<$StepSpecs extends Step[] = Step[]> { + steps: $StepSpecs + input: $StepSpecs extends Tuple.NonEmpty + ? $StepSpecs[0]['input'] + : object + output: Awaited< + $StepSpecs extends Tuple.NonEmpty + ? Tuple.GetLastValue<$StepSpecs>['output'] + : unknown + > +} + +type InferStepSpecs<$StepSpecInputs extends Step.SpecInput[]> = InferStepSpecs_ + +// dprint-ignore +type InferStepSpecs_<$StepSpecPrevious extends Step| undefined, $StepSpecInputs extends Step.SpecInput[]> = + $StepSpecInputs extends [infer $StepSpecInput extends Step.SpecInput, ...infer $StepSpecInputsRest extends Step.SpecInput[]] + ? InferStepSpecs__<{ + name: $StepSpecInput['name'] + slots: IsUnknown<$StepSpecInput['slots']> extends true ? undefined : $StepSpecInput['slots'] + input: IsUnknown<$StepSpecInput['input']> extends true + ? $StepSpecPrevious extends Step + ? $StepSpecPrevious['output'] + : object + : $StepSpecInput['input'] + output: MaybePromise< + Awaited< + IsUnknown<$StepSpecInput['output']> extends true + ? $StepSpecInputsRest extends Tuple.NonEmpty + ? $StepSpecInputsRest[0]['input'] extends undefined + ? unknown + : $StepSpecInputsRest[0]['input'] + : unknown + : $StepSpecInput['output'] + > + > + }, $StepSpecInputsRest> + : [] + +type InferStepSpecs__<$StepSpec extends Step, $StepSpecInputsRest extends Step.SpecInput[]> = [ + $StepSpec, + ...InferStepSpecs_<$StepSpec, $StepSpecInputsRest>, +] diff --git a/src/lib/anyware/Pipeline/_.ts b/src/lib/anyware/Pipeline/_.ts new file mode 100644 index 000000000..93ccb23dc --- /dev/null +++ b/src/lib/anyware/Pipeline/_.ts @@ -0,0 +1,6 @@ +export * from '../run/run.js' +export * from './builder.js' +export * from './createWithSpec.js' +export * from './Executable.js' +export * from './Result.js' +export * from './Spec.js' diff --git a/src/lib/anyware/Pipeline/__.ts b/src/lib/anyware/Pipeline/__.ts new file mode 100644 index 000000000..5ffe95bb1 --- /dev/null +++ b/src/lib/anyware/Pipeline/__.ts @@ -0,0 +1 @@ +export * as Pipeline from './_.js' diff --git a/src/lib/anyware/Pipeline/builder.test-d.ts b/src/lib/anyware/Pipeline/builder.test-d.ts new file mode 100644 index 000000000..30a50c6aa --- /dev/null +++ b/src/lib/anyware/Pipeline/builder.test-d.ts @@ -0,0 +1,86 @@ +import { expectTypeOf, test } from 'vitest' +import type { initialInput } from '../__.test-helpers.js' +import { results, slots, stepA } from '../__.test-helpers.js' +import { Pipeline } from './__.js' +import type { Config } from './Config.js' + +const b0 = Pipeline.create() + +test(`initial context`, () => { + expectTypeOf(b0.context).toEqualTypeOf<{ input: initialInput; steps: []; config: Config }>() +}) + +test(`first step definition`, () => { + expectTypeOf(b0.step).toMatchTypeOf< + (input: { name: string; run: (params: { input: initialInput; previous: undefined }) => any }) => any + >() +}) + +test(`second step definition`, () => { + const p1 = b0.step({ name: `a`, run: () => results.a }) + expectTypeOf(p1.step).toMatchTypeOf< + ( + input: { + name: string + run: (params: { + input: results['a'] + slots: undefined + previous: { a: { output: results['a'] } } + }) => any + }, + ) => any + >() + expectTypeOf(p1.context).toMatchTypeOf< + { + input: initialInput + steps: [{ name: 'a'; slots: undefined; run: any }] + config: Config + } + >() +}) +test(`step input receives awaited return value from previous step `, () => { + const p1 = b0.step({ name: `a`, run: () => Promise.resolve(results.a) }) + type s2Parameters = Parameters[0]['run']>[0]['input'] + expectTypeOf().toEqualTypeOf() +}) + +test(`step definition with slots`, () => { + const p1 = b0 + .step({ + name: `a`, + slots: { + m: slots.m, + n: slots.n, + }, + run: ({ slots }) => { + expectTypeOf(slots.m()).toEqualTypeOf>() + expectTypeOf(slots.n()).toEqualTypeOf<'n'>() + return results.a + }, + }) + expectTypeOf(p1.context).toMatchTypeOf< + { + input: initialInput + config: Config + steps: [{ + name: 'a' + slots: slots + run: (params: { + input: initialInput + slots: slots + previous: undefined + }) => any + }] + } + >() +}) + +test(`.done() returns a pipeline`, () => { + const p0 = b0.done() + expectTypeOf().toMatchTypeOf<{ config: Config; steps: []; input: initialInput; output: initialInput }>() + + const p1 = b0.step(stepA).done() + expectTypeOf().toMatchTypeOf< + { config: Config; steps: [{ name: `a`; run: any }]; input: initialInput; output: results['a'] } + >() +}) diff --git a/src/lib/anyware/Pipeline/builder.ts b/src/lib/anyware/Pipeline/builder.ts index 94745c8ee..3671db89d 100644 --- a/src/lib/anyware/Pipeline/builder.ts +++ b/src/lib/anyware/Pipeline/builder.ts @@ -1,83 +1,161 @@ -import { type FindValueAfter, type IsLastValue, type MaybePromise } from '../../prelude.js' -import type { HookDefinitionMap, HookSequence } from '../hook/definition.js' -import type { HookResultError, InferPrivateHookInput } from '../hook/private.js' -import { createRunner, type Runner } from '../run/runner.js' -import type { Pipeline } from './Pipeline.js' +import type { ConfigManager } from '../../config-manager/__.js' +import { type Tuple } from '../../prelude.js' +import type { ExecutableStep } from '../ExecutableStep.js' +import type { Step } from '../Step.js' +import { type Config, type Options, resolveOptions } from './Config.js' +import { createExecutableStepsIndex } from './createWithSpec.js' +import type { StepsIndex } from './Executable.js' -export { type HookDefinitionMap } from '../hook/definition.js' +export interface Context { + config: Config + input: object + steps: ExecutableStep[] +} -export type PipelineInput< - $HookSequence extends HookSequence = HookSequence, - $HookMap extends HookDefinitionMap<$HookSequence> = HookDefinitionMap<$HookSequence>, - $Result = unknown, -> = { - hookNamesOrderedBySequence: $HookSequence - // dprint-ignore - hooks: { - [$HookName in $HookSequence[number]]: - keyof $HookMap[$HookName]['slots'] extends never - ? (input: InferPrivateHookInput<$HookSequence, $HookMap, $HookName>) => - MaybePromise< - IsLastValue<$HookName, $HookSequence> extends true - ? $Result - : $HookMap[FindValueAfter<$HookName, $HookSequence>]['input'] - > - : { - slots: $HookMap[$HookName]['slots'] - run: (input: InferPrivateHookInput<$HookSequence, $HookMap, $HookName>) => - MaybePromise< - IsLastValue<$HookName, $HookSequence> extends true ? $Result - : $HookMap[FindValueAfter<$HookName, $HookSequence>]['input'] - > - } - } - /** - * If a hook results in a thrown error but is an instance of one of these classes then return it as-is - * rather than wrapping it in a ContextualError. - * - * This can be useful when there are known kinds of errors such as Abort Errors from AbortController - * which are actually a signaling mechanism. - */ - passthroughErrorInstanceOf?: Function[] +export interface ContextEmpty extends Context { + input: object + steps: [] + config: Config +} + +/** + * Get the `input` parameter for a step that would be appended to the given Pipeline. + * + * Recall that non-first steps have input corresponding to the output of the previous step. + * + * So this returns: + * - If the pipeline has no steps then the pipeline input itself. + * - Otherwise the last step's output. + */ +// dprint-ignore +type GetNextStepParameterInput<$Context extends Context> = + $Context['steps'] extends Tuple.NonEmpty + ? Awaited['output']> + : $Context['input'] + +export interface Builder<$Context extends Context = Context> { + context: $Context /** - * If a hook results in a thrown error but returns true from this function then return the error as-is - * rather than wrapping it in a ContextualError. - * - * This can be useful when there are known kinds of errors such as Abort Errors from AbortController - * which are actually a signaling mechanism. + * todo */ - passthroughErrorWith?: (signal: HookResultError) => boolean + step: < + const $Name extends string, + $Run extends (params: { + input: GetNextStepParameterInput<$Context> + slots: $Slots + previous: GetNextStepParameterPrevious<$Context> + }) => any, + $Slots extends undefined | Step.Slots = undefined, + $Params extends { + input: GetNextStepParameterInput<$Context> + slots: $Slots + previous: GetNextStepParameterPrevious<$Context> + } = { + input: GetNextStepParameterInput<$Context> + slots: $Slots + previous: GetNextStepParameterPrevious<$Context> + }, + >( + parameters: { + name: $Name + slots?: $Slots + run: $Run + }, + ) => Builder< + ConfigManager.SetOneKey< + $Context, + 'steps', + [ + ...$Context['steps'], + { + name: $Name + input: $Params['input'] + output: ReturnType<$Run> + slots: $Slots + run: $Run + }, + ] + > + > + done: () => InferPipelineFromContext<$Context> } -export const create = < - $HookSequence extends HookSequence = HookSequence, - $HookMap extends HookDefinitionMap<$HookSequence> = HookDefinitionMap<$HookSequence>, - $Result = unknown, ->( - input: PipelineInput<$HookSequence, $HookMap, $Result>, -): Builder> => { - type $Pipeline = Pipeline<$HookSequence, $HookMap, $Result> +// dprint-ignore +export type GetNextStepParameterPrevious<$Context extends Context> = + $Context['steps'] extends Tuple.NonEmpty + ? GetNextStepPrevious_<$Context['steps']> + : undefined - const pipeline = { - ...input, - hooks: Object.fromEntries( - Object.entries(input.hooks).map(([k, v]) => { - return [k, typeof v === `function` ? { slots: {}, run: v } : v] - }), - ), - } as any as $Pipeline +type GetNextStepPrevious_<$Steps extends Step[]> = Tuple.IntersectItems< + { + [$Index in keyof $Steps]: { + [$StepName in $Steps[$Index]['name']]: { + input: Awaited<$Steps[$Index]['input']> + output: Awaited<$Steps[$Index]['output']> + } + } + } +> - const run = createRunner(pipeline) +export type InferPipeline<$Builder extends Builder> = InferPipelineFromContext<$Builder['context']> - const builder: Builder<$Pipeline> = { - pipeline, - run, +// dprint-ignore +type InferPipelineFromContext<$Context extends Context> = + & $Context + & { + stepsIndex: StepsIndex + /** + * The overall result of the pipeline. + * + * If the pipeline has no steps then is the pipeline input itself. + * Otherwise is the last step's output. + */ + output: + // Promise< + // Result< + Awaited< + $Context['steps'] extends Tuple.NonEmpty + ? Tuple.GetLastValue<$Context['steps']>['output'] + : $Context['input'] + > + // > + // > } - return builder +/** + * TODO + */ +export const create = <$Input extends object>(options?: Options): Builder<{ + input: $Input + steps: [] + config: Config +}> => { + const config = resolveOptions(options) + return recreate({ + steps: [], + config, + } as any) } -export type Builder<$Pipeline extends Pipeline> = { - pipeline: $Pipeline - run: Runner<$Pipeline> +const recreate = <$Context extends Context>(context: $Context): Builder<$Context> => { + return { + context, + step: (parameters) => { + return recreate({ + ...context, + steps: [ + ...context.steps, + parameters, + ], + } as any) + }, + done: () => { + const pipeline = context + const stepsIndex = createExecutableStepsIndex(pipeline.steps) + return { + ...pipeline, + stepsIndex, + } as any + }, + } } diff --git a/src/lib/anyware/Pipeline/createWithSpec.test-d.ts b/src/lib/anyware/Pipeline/createWithSpec.test-d.ts new file mode 100644 index 000000000..9f18c192e --- /dev/null +++ b/src/lib/anyware/Pipeline/createWithSpec.test-d.ts @@ -0,0 +1,30 @@ +import { assertEqual } from '../../assert-equal.js' +import type { MaybePromise } from '../../prelude.js' +import type { Config } from './Config.js' +import { createWithSpec } from './createWithSpec.js' +import type { StepsIndex } from './Executable.js' +import type { PipelineSpecFromSteps } from './Spec.js' + +{ + type Spec = PipelineSpecFromSteps<[]> + const p = createWithSpec({ steps: [] }) + assertEqual() +} + +{ + type Spec = PipelineSpecFromSteps<[{ name: 'a'; output: 1 }]> + const p = createWithSpec({ steps: [{ name: `a`, run: () => 1 }] }) + type p = typeof p + assertEqual< + p, + { + config: Config + input: object + output: 1 + steps: [ + { run: (...arg: any[]) => any; name: `a`; input: object; output: MaybePromise<1> }, + ] + stepsIndex: StepsIndex + } + >() +} diff --git a/src/lib/anyware/Pipeline/createWithSpec.ts b/src/lib/anyware/Pipeline/createWithSpec.ts new file mode 100644 index 000000000..bff275f39 --- /dev/null +++ b/src/lib/anyware/Pipeline/createWithSpec.ts @@ -0,0 +1,102 @@ +import type { ConfigManager } from '../../config-manager/__.js' +import type { Tuple } from '../../prelude.js' +import type { ExecutableStepRuntime } from '../ExecutableStep.js' +import type { Step } from '../Step.js' +import { type Config, type Options, resolveOptions } from './Config.js' +import type { StepsIndex } from './Executable.js' +import type { PipelineSpec } from './Spec.js' + +export const createExecutableStepsIndex = <$Steps extends ExecutableStepRuntime[]>(steps: $Steps): StepsIndex => { + return new Map(steps.map(step => [step.name, step])) +} + +export const createWithSpec = <$PipelineSpec extends PipelineSpec>( + input: { + options?: Options + steps: InferStepsInput<$PipelineSpec['steps']> + }, +): InferPipelineExecutable<$PipelineSpec> => { + const config = resolveOptions(input.options) + const stepsIndex = createExecutableStepsIndex(input.steps) + return { + config, + stepsIndex, + steps: input.steps, + } as any +} + +type InferPipelineExecutable<$PipelineSpec extends PipelineSpec> = { + input: $PipelineSpec['input'] + output: $PipelineSpec['output'] + steps: InferExecutableSteps<$PipelineSpec['steps']> + stepsIndex: StepsIndex + config: Config +} + +type InferStepsInput<$NextStepDefinitions extends Step[]> = InferExecutableSteps_< + [], + $NextStepDefinitions, + { types: false } +> + +type InferExecutableSteps<$NextStepDefinitions extends Step[]> = InferExecutableSteps_< + [], + $NextStepDefinitions, + { types: true } +> + +// dprint-ignore +type InferExecutableSteps_< + $PreviousStepSpecs extends Step[], + $NextStepSpecs extends Step[], + $Options extends { types: boolean }, +> = + $NextStepSpecs extends [infer $StepSpec extends Step, ...infer $RestStepSpecs extends Step[]] + ? [ + & ( + $Options['types'] extends true + ? { + input: $StepSpec['input'] + output: $StepSpec['output'] + } + : {} + ) + & { + name: $StepSpec['name'] + run: (parameters: + & { + input: $StepSpec['input'] + previous: GetParameterPrevious<$PreviousStepSpecs> + } + & ( + $StepSpec['slots'] extends Step.Slots + ? { + slots: $StepSpec['slots'] + } + : {} + ) + ) => $StepSpec['output'] + } + & ( + $StepSpec['slots'] extends Step.Slots + ? { + slots: $StepSpec['slots'] + } + : {} + ) + , ...InferExecutableSteps_<[...$PreviousStepSpecs, $StepSpec], $RestStepSpecs, $Options>] + : [] + +export type GetParameterPrevious<$StepDefinitions extends Step[]> = Tuple.IntersectItems< + { + [$Index in keyof $StepDefinitions]: { + [$StepName in $StepDefinitions[$Index]['name']]: { + input: $StepDefinitions[$Index]['input'] + output: ConfigManager.OrDefault< + $StepDefinitions[$Index]['output'], + Tuple.GetAtNextIndex<$StepDefinitions, $Index>['input'] + > + } + } + } +> diff --git a/src/lib/anyware/Pipeline/run.test-d.ts b/src/lib/anyware/Pipeline/run.test-d.ts new file mode 100644 index 000000000..cb1ba19c7 --- /dev/null +++ b/src/lib/anyware/Pipeline/run.test-d.ts @@ -0,0 +1,22 @@ +import { expectTypeOf, test } from 'vitest' +import type { initialInput } from '../__.test-helpers.js' +import { Pipeline } from './__.js' +import type { Result } from './Result.js' + +test(`returns input if no steps`, async () => { + const p = Pipeline.create().done() + const r = await Pipeline.run(p) + expectTypeOf(r).toEqualTypeOf>() +}) + +test(`returns last step output if steps`, async () => { + const p = Pipeline.create().step({ name: `a`, run: () => 2 as const }).done() + const r = await Pipeline.run(p) + expectTypeOf(r).toEqualTypeOf>() +}) + +test(`can return a promise`, async () => { + const p = Pipeline.create().step({ name: `a`, run: () => Promise.resolve(2 as const) }).done() + const r = await Pipeline.run(p) + expectTypeOf(r).toEqualTypeOf>() +}) diff --git a/src/lib/anyware/Step.ts b/src/lib/anyware/Step.ts new file mode 100644 index 000000000..235595428 --- /dev/null +++ b/src/lib/anyware/Step.ts @@ -0,0 +1,53 @@ +import type { SomeFunction } from '../prelude.js' + +export interface Step< + $Name extends string = string, +> { + name: $Name + slots?: Step.Slots + input: any + output: any +} + +export namespace Step { + export interface SpecInput { + name: string + slots?: Step.Slots + input?: object + output?: unknown + } + + /** + * todo + */ + export const createWithInput = < + $Input extends Input = Input, + >() => + < + const $Name extends string, + $Run extends ImplementationFn<$Input>, + $Slots extends undefined | Step.Slots, + >( + parameters: { + name: $Name + slots?: $Slots + run: $Run + }, + ): { + name: $Name + run: $Run + input: Parameters<$Run>[0]['input'] + output: ReturnType<$Run> + slots: undefined extends $Slots ? undefined : $Slots + } => { + return parameters as any + } + + type ImplementationFn<$Input extends Input = Input> = (parameters: { input: $Input }) => any + + export type Input = object + + export type Slots = Record + + export type Name = string +} diff --git a/src/lib/anyware/StepResult.ts b/src/lib/anyware/StepResult.ts new file mode 100644 index 000000000..0b1f6f0a6 --- /dev/null +++ b/src/lib/anyware/StepResult.ts @@ -0,0 +1,49 @@ +import type { Errors } from '../errors/__.js' +import type { Deferred } from '../prelude.js' +import type { InterceptorGeneric } from './Interceptor/Interceptor.js' + +export type StepResult = + | StepResultCompleted + | StepResultShortCircuited + | StepResultErrorUser + | StepResultErrorImplementation + | StepResultErrorExtension + +export interface StepResultShortCircuited { + type: 'shortCircuited' + result: unknown +} + +export interface StepResultCompleted { + type: 'completed' + effectiveInput: object + result: unknown + nextExtensionsStack: readonly InterceptorGeneric[] +} + +export type StepResultError = StepResultErrorExtension | StepResultErrorImplementation | StepResultErrorUser + +export interface StepResultErrorUser { + type: 'error' + hookName: string + source: 'user' + error: Errors.ContextualError + extensionName: string +} + +export interface StepResultErrorExtension { + type: 'error' + hookName: string + source: 'extension' + error: Error + interceptorName: string +} + +export interface StepResultErrorImplementation { + type: 'error' + hookName: string + source: 'implementation' + error: Error +} + +export type StepResultErrorAsync = Deferred diff --git a/src/lib/anyware/StepTrigger.ts b/src/lib/anyware/StepTrigger.ts new file mode 100644 index 000000000..dffd5624f --- /dev/null +++ b/src/lib/anyware/StepTrigger.ts @@ -0,0 +1,66 @@ +import type { Simplify } from 'type-fest' +import type { Func } from '../prelude.js' +import type { Step } from './Step.js' + +export namespace StepTrigger { + export const create = <$OriginalInput, $Fn extends StepTriggerRaw>( + originalInput: $OriginalInput, + fn: $Fn, + ): StepTrigger<$Fn> => { + // ): $Hook & { input: $OriginalInput } => { + // @ts-expect-error + fn.input = originalInput + // @ts-expect-error + return fn + } + + export interface Properties< + $OriginalInput extends Step.Input = Step.Input, + > { + [stepTriggerSymbol]: StepTriggerSymbol + // todo the result is unknown, but if we build a EndEnvelope, then we can work with this type more logically and put it here. + // E.g. adding `| unknown` would destroy the knowledge of hook envelope case + // todo this is not strictly true, it could also be the final result + input: $OriginalInput + } + + // dprint-ignore + export interface Infer< + $Step extends Step, + $NextSteps extends Step[], + $PipelineOutput> extends StepTrigger.Properties<$Step['input'] + > { + ( + params?: Simplify< + & { + input?: $Step['input'] + } + & ( + $Step['slots'] extends undefined + ? {} + : { using?: { + [$SlotName in keyof $Step['slots']]?: Func.AppendAwaitedReturnType<$Step['slots'][$SlotName], undefined> + } + } + ) + > + ): Promise< + $NextSteps extends [infer $NextStep extends Step, ...infer $NextNextSteps extends Step[]] + ? { + [_ in $NextStep['name']]: Infer<$NextStep, $NextNextSteps, $PipelineOutput> + } + : $PipelineOutput + > + } +} + +export type StepTrigger< + $Fn extends StepTriggerRaw = StepTriggerRaw, + $OriginalInput extends Step.Input = Step.Input, +> = $Fn & StepTrigger.Properties<$OriginalInput> + +type StepTriggerRaw = (input?: { input?: any; using?: any }) => any + +const stepTriggerSymbol = Symbol(`hook`) + +type StepTriggerSymbol = typeof stepTriggerSymbol diff --git a/src/lib/anyware/StepTriggerEnvelope.ts b/src/lib/anyware/StepTriggerEnvelope.ts new file mode 100644 index 000000000..ee252d243 --- /dev/null +++ b/src/lib/anyware/StepTriggerEnvelope.ts @@ -0,0 +1,5 @@ +import type { StepTrigger } from './StepTrigger.js' + +export type StepTriggerEnvelope = { + [name: string]: StepTrigger +} diff --git a/src/lib/anyware/_.ts b/src/lib/anyware/_.ts index 917c6e340..a988c4cc0 100644 --- a/src/lib/anyware/_.ts +++ b/src/lib/anyware/_.ts @@ -1,4 +1,4 @@ -export * from './Interceptor.js' -export * from './Pipeline/builder.js' -export * from './Pipeline/Pipeline.js' +export * from './Interceptor/Interceptor.js' +export * from './Pipeline/__.js' +export * from './Pipeline/Spec.js' export * from './run/runner.js' diff --git a/src/lib/anyware/__.entrypoint.test.ts b/src/lib/anyware/__.entrypoint.test.ts index 46f78619f..9502c29c7 100644 --- a/src/lib/anyware/__.entrypoint.test.ts +++ b/src/lib/anyware/__.entrypoint.test.ts @@ -2,11 +2,21 @@ import { describe, expect, test } from 'vitest' import type { ContextualAggregateError } from '../errors/ContextualAggregateError.js' -import { run } from './__.test-helpers.js' +import { _ } from '../prelude.js' +import { Pipeline } from './_.js' +import { initialInput, stepA, stepB } from './__.test-helpers.js' + +const run = async (interceptor: (...args: any[]) => any) => { + const pipeline = Pipeline.create().step(stepA).step(stepB).done() + return Pipeline.run(pipeline, { + initialInput, + interceptors: [interceptor], + }) +} describe(`invalid destructuring cases`, () => { test(`noParameters`, async () => { - const result = await run(() => 1) as ContextualAggregateError + const result = await run(() => _) as ContextualAggregateError expect({ result, errors: result.errors, @@ -17,14 +27,14 @@ describe(`invalid destructuring cases`, () => { "issue": "noParameters", }, "errors": [ - [ContextualError: Interceptor must destructure the first parameter passed to it and select exactly one entrypoint.], + [ContextualError: Interceptor must destructure the first parameter passed to it and select exactly one step.], ], "result": [ContextualAggregateError: One or more extensions are invalid.], } `) }) test(`destructuredWithoutEntryHook`, async () => { - const result = await run(async ({ x }) => {}) as ContextualAggregateError + const result = await run(async ({}) => {}) as ContextualAggregateError expect({ result, errors: result.errors, @@ -33,10 +43,10 @@ describe(`invalid destructuring cases`, () => { ` { "context": { - "issue": "destructuredWithoutEntryHook", + "issue": "noParameters", }, "errors": [ - [ContextualError: Interceptor must destructure the first parameter passed to it and select exactly one entrypoint.], + [ContextualError: Interceptor must destructure the first parameter passed to it and select exactly one step.], ], "result": [ContextualAggregateError: One or more extensions are invalid.], } @@ -44,7 +54,6 @@ describe(`invalid destructuring cases`, () => { ) }) test(`multipleParameters`, async () => { - // @ts-expect-error two parameters is invalid const result = await run(async ({ x }, y) => {}) as ContextualAggregateError expect({ result, @@ -57,7 +66,7 @@ describe(`invalid destructuring cases`, () => { "issue": "multipleParameters", }, "errors": [ - [ContextualError: Interceptor must destructure the first parameter passed to it and select exactly one entrypoint.], + [ContextualError: Interceptor must destructure the first parameter passed to it and select exactly one step.], ], "result": [ContextualAggregateError: One or more extensions are invalid.], } @@ -76,7 +85,7 @@ describe(`invalid destructuring cases`, () => { "issue": "notDestructured", }, "errors": [ - [ContextualError: Interceptor must destructure the first parameter passed to it and select exactly one entrypoint.], + [ContextualError: Interceptor must destructure the first parameter passed to it and select exactly one step.], ], "result": [ContextualAggregateError: One or more extensions are invalid.], } @@ -94,7 +103,25 @@ describe(`invalid destructuring cases`, () => { "issue": "multipleDestructuredHookNames", }, "errors": [ - [ContextualError: Interceptor must destructure the first parameter passed to it and select exactly one entrypoint.], + [ContextualError: Interceptor must destructure the first parameter passed to it and select exactly one step.], + ], + "result": [ContextualAggregateError: One or more extensions are invalid.], + } + `) + }) + test(`invalidDestructuredHookNames`, async () => { + const result = await run(async ({ y, z }) => {}) as ContextualAggregateError + expect({ + result, + errors: result.errors, + context: result.errors[0]?.context, + }).toMatchInlineSnapshot(` + { + "context": { + "issue": "invalidDestructuredHookNames", + }, + "errors": [ + [ContextualError: Interceptor must destructure the first parameter passed to it and select exactly one step.], ], "result": [ContextualAggregateError: One or more extensions are invalid.], } diff --git a/src/lib/anyware/__.test-d.ts b/src/lib/anyware/__.test-d.ts deleted file mode 100644 index 323d3f42d..000000000 --- a/src/lib/anyware/__.test-d.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* eslint-disable */ - -import { describe, expectTypeOf, test } from 'vitest' -import { ContextualError } from '../errors/ContextualError.js' -import { type MaybePromise } from '../prelude.js' -import { Anyware } from './__.js' -import type { PublicHook } from './hook/public.js' - -type InputA = { valueA: string } -type InputB = { valueB: string } -type Result = { return: string } - -const create = Anyware.create<['a', 'b'], { a: { input: InputA }; b: { input: InputB } }, Result> - -describe('without slots', () => { - test('create', () => { - expectTypeOf(create).toMatchTypeOf< - (input: { - hookNamesOrderedBySequence: ['a', 'b'] - hooks: { - a: (input: { input: InputA; previous: {} }) => InputB - b: (input: { input: InputB; previous: { a: { input: InputA } } }) => Result - } - }) => any - >() - }) - - test('run', () => { - type run = ReturnType['run'] - - expectTypeOf().toMatchTypeOf< - (input: { - initialInput: InputA - options?: Anyware.Options - retryingExtension?: (input: { - a: PublicHook< - (params?: { input?: InputA }) => MaybePromise< - Error | { - b: PublicHook< - (params?: { input?: InputB }) => MaybePromise - > - } - > - > - b: PublicHook<(params?: { input?: InputB }) => MaybePromise> - }) => Promise - interceptors: ((input: { - a: PublicHook< - (params?: { input?: InputA }) => MaybePromise<{ - b: PublicHook<(params?: { input?: InputB }) => MaybePromise> - }> - > - b: PublicHook<(params?: { input?: InputB }) => MaybePromise> - }) => Promise)[] - }) => Promise - >() - }) -}) - -describe('withSlots', () => { - const create = Anyware.create<['a'], { a: { input: InputA; slots: { x: (x: boolean) => number } } }, Result> - - test('create', () => { - expectTypeOf(create).toMatchTypeOf< - (input: { - hookNamesOrderedBySequence: ['a'] - hooks: { - a: { - run: (input: { input: InputA; previous: {} }) => Result - slots: { - x: (x: boolean) => number - } - } - } - }) => any - >() - }) - - test('run', () => { - type run = ReturnType['run'] - - expectTypeOf().toMatchTypeOf< - (input: { - initialInput: InputA - options?: Anyware.Options - interceptors: ((input: { - a: PublicHook< - ( - input?: { input?: InputA; using?: { x?: (x: boolean) => number | undefined } }, - ) => MaybePromise - > - }) => Promise)[] - retryingExtension?: (input: { - a: PublicHook< - (input?: { input?: InputA; using?: { x?: (x: boolean) => number | undefined } }) => MaybePromise< - Error | Result - > - > - }) => Promise - }) => Promise - >() - }) -}) diff --git a/src/lib/anyware/__.test-helpers.ts b/src/lib/anyware/__.test-helpers.ts index b75624bf9..34f070d79 100644 --- a/src/lib/anyware/__.test-helpers.ts +++ b/src/lib/anyware/__.test-helpers.ts @@ -1,8 +1,33 @@ -import type { Mock } from 'vitest' +import { keyBy } from 'es-toolkit' import { beforeEach, vi } from 'vitest' -import { Anyware } from './__.js' -import type { InterceptorInput } from './Interceptor.js' -import type { Options } from './run/runner.js' +import type { Tuple } from '../prelude.js' +import { Pipeline } from './_.js' +import type { NonRetryingInterceptorInput } from './Interceptor/Interceptor.js' +import type { Options } from './Pipeline/Config.js' +import type { PipelineExecutable } from './Pipeline/Executable.js' +import { Step } from './Step.js' + +export const initialInput = { x: 1 } as const +export type initialInput = typeof initialInput + +export const initialInput2 = { value: `initial` } as const + +export const results = { + a: { a: 1 }, + b: { b: 2 }, + c: { c: 3 }, +} as const +export type results = typeof results + +export const stepA = Step.createWithInput()({ name: `a`, run: () => results[`a`] }) +export const stepB = Step.createWithInput()({ name: `b`, run: () => results[`b`] }) +export const stepC = Step.createWithInput()({ name: `c`, run: () => results[`c`] }) + +export const slots = { + m: () => Promise.resolve(`m` as const), + n: () => `n` as const, +} +export type slots = typeof slots type PrivateHookRunnerInput = { input: { value: string } @@ -10,92 +35,81 @@ type PrivateHookRunnerInput = { previous: object } -type PrivateHookRunner = (input: PrivateHookRunnerInput) => any - -export const initialInput: PrivateHookRunnerInput['input'] = { value: `initial` } - -type $Core = ReturnType & { - hooks: { - a: { - run: Mock - slots: { - append: Mock<(hookName: string) => string> - appendExtra: Mock<(hookName: string) => string> - } - } - b: { - run: Mock +export const createPipeline = (options?: Options) => { + return Pipeline + .create<{ value: string }>(options) + .step({ + name: `a`, slots: { - append: Mock<(hookName: string) => string> - appendExtra: Mock<(hookName: string) => string> - } - } - } -} - -export const createHook = <$Slots extends object, $Input extends object, $Result = unknown>( - $Hook: { - slots: $Slots - run: (input: { input: $Input; slots: $Slots }) => $Result - }, -) => $Hook - -export const createAnyware = () => { - const a = createHook({ - slots: { - append: vi.fn().mockImplementation((hookName: string) => { - return hookName - }), - appendExtra: vi.fn().mockImplementation(() => { - return `` + append: vi.fn().mockImplementation((hookName: string) => { + return hookName + }), + appendExtra: vi.fn().mockImplementation(() => { + return `` + }), + }, + run: vi.fn().mockImplementation(({ input, slots }: PrivateHookRunnerInput) => { + const extra = slots.appendExtra(`a`) + return { value: input.value + `+` + slots.append(`a`) + extra } }), - }, - run: vi.fn().mockImplementation(({ input, slots }: PrivateHookRunnerInput) => { - const extra = slots.appendExtra(`a`) - return { value: input.value + `+` + slots.append(`a`) + extra } - }), - }) - const b = createHook({ - slots: { - append: vi.fn().mockImplementation((hookName: string) => { - return hookName - }), - appendExtra: vi.fn().mockImplementation(() => { - return `` + }) + .step({ + name: `b`, + slots: { + append: vi.fn().mockImplementation((hookName: string) => { + return hookName + }), + appendExtra: vi.fn().mockImplementation(() => { + return `` + }), + }, + run: vi.fn().mockImplementation(({ input, slots }: PrivateHookRunnerInput) => { + const extra = slots.appendExtra(`b`) + return { value: input.value + `+` + slots.append(`b`) + extra } }), - }, - run: vi.fn().mockImplementation(({ input, slots }: PrivateHookRunnerInput) => { - const extra = slots.appendExtra(`b`) - return { value: input.value + `+` + slots.append(`b`) + extra } - }), - }) - - return Anyware.create<['a', 'b'], Anyware.HookDefinitionMap<['a', 'b']>, PrivateHookRunnerInput>({ - hookNamesOrderedBySequence: [`a`, `b`], - hooks: { a, b }, - }) + }) + .done() } -// @ts-expect-error -export let anyware: Anyware.Builder<$Core> = null -export let core: $Core +type TestPipeline = ReturnType + +export let stepsIndex: Tuple.ToIndexByObjectKey +let pipeline: PipelineExecutable beforeEach(() => { - // @ts-expect-error mock types not tracked by Anyware - anyware = createAnyware() - core = anyware.pipeline + pipeline = createPipeline() + stepsIndex = keyBy(pipeline.steps, _ => _.name) as any }) -export const runWithOptions = (options: Options = {}) => async (...interceptors: InterceptorInput[]) => { - const result = await anyware.run({ - initialInput, - // @ts-expect-error fixme +export const runWithOptions = (options?: Options) => { + const pipeline = createPipeline(options) + const run = async (...interceptors: NonRetryingInterceptorInput[]) => { + return await Pipeline.run(pipeline, { + initialInput: { value: `initial` }, + interceptors, + }) + } + stepsIndex = keyBy(pipeline.steps, _ => _.name) as any + return { + pipeline, + stepsIndex, + run, + } +} + +export const run = async (...interceptors: NonRetryingInterceptorInput[]) => { + return await Pipeline.run(pipeline, { + initialInput: initialInput2, interceptors, - options, }) - return result } -export const run = async (...extensions: InterceptorInput[]) => runWithOptions({})(...extensions) +export const runRetrying = async (interceptor: NonRetryingInterceptorInput) => { + return await Pipeline.run(pipeline, { + initialInput: initialInput2, + interceptors: [], + retryingInterceptor: interceptor, + }) +} export const oops = new Error(`oops`) diff --git a/src/lib/anyware/hook/definition.ts b/src/lib/anyware/hook/definition.ts deleted file mode 100644 index 57f6c57d8..000000000 --- a/src/lib/anyware/hook/definition.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type HookSequence = readonly [string, ...string[]] - -export type HookDefinitionMap<$HookSequence extends HookSequence> = Record< - $HookSequence[number], - HookDefinition -> - -export type HookDefinition = { - input: any /* object <- type error but more accurate */ - slots?: any /* object <- type error but more accurate */ -} - -export type HookName = string diff --git a/src/lib/anyware/hook/private.ts b/src/lib/anyware/hook/private.ts deleted file mode 100644 index fc7105e8c..000000000 --- a/src/lib/anyware/hook/private.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { Errors } from '../../errors/__.js' -import type { Deferred, MaybePromise, SomeFunction, TakeValuesBefore } from '../../prelude.js' -import type { InterceptorGeneric } from '../Interceptor.js' -import type { HookDefinitionMap, HookSequence } from './definition.js' - -export type InferPrivateHookInput< - $HookSequence extends HookSequence, - $HookMap extends HookDefinitionMap<$HookSequence>, - $HookName extends string, -> = HookPrivateInput< - $HookMap[$HookName]['input'], - $HookMap[$HookName]['slots'], - { - [$PreviousHookName in TakeValuesBefore<$HookName, $HookSequence>[number]]: { - input: $HookMap[$PreviousHookName]['input'] - } - } -> - -export type PrivateHook<$Slots extends Slots, $Input extends HookPrivateInput, $Return> = { - slots: $Slots - run: (input: $Input) => MaybePromise<$Return> -} - -export type HookPrivateInput< - $Input extends object | undefined = object | undefined, - $Slots extends Slots | undefined = Slots | undefined, - $Previous extends object = object, -> = { - input: $Input - slots: $Slots - previous: $Previous -} - -export type Slots = Record - -export type HookResult = - | HookResultCompleted - | HookResultShortCircuited - | HookResultErrorUser - | HookResultErrorImplementation - | HookResultErrorExtension - -export interface HookResultShortCircuited { - type: 'shortCircuited' - result: unknown -} - -export interface HookResultCompleted { - type: 'completed' - effectiveInput: object - result: unknown - nextExtensionsStack: readonly InterceptorGeneric[] -} - -export type HookResultError = HookResultErrorExtension | HookResultErrorImplementation | HookResultErrorUser - -export interface HookResultErrorUser { - type: 'error' - hookName: string - source: 'user' - error: Errors.ContextualError - extensionName: string -} - -export interface HookResultErrorExtension { - type: 'error' - hookName: string - source: 'extension' - error: Error - interceptorName: string -} - -export interface HookResultErrorImplementation { - type: 'error' - hookName: string - source: 'implementation' - error: Error -} - -export type HookResultErrorAsync = Deferred diff --git a/src/lib/anyware/hook/public.ts b/src/lib/anyware/hook/public.ts deleted file mode 100644 index eef667859..000000000 --- a/src/lib/anyware/hook/public.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { FindValueAfter, IsLastValue } from '../../prelude.js' -import type { InterceptorOptions } from '../Interceptor.js' -import type { HookDefinition, HookDefinitionMap, HookSequence } from './definition.js' - -export type InferPublicHooks< - $HookSequence extends HookSequence, - $HookMap extends Record<$HookSequence[number], HookDefinition> = Record<$HookSequence[number], HookDefinition>, - $Result = unknown, - $Options extends InterceptorOptions = InterceptorOptions, -> = { - [$HookName in $HookSequence[number]]: InferPublicHook<$HookSequence, $HookMap, $Result, $HookName, $Options> -} - -type InferPublicHook< - $HookSequence extends HookSequence, - $HookMap extends HookDefinitionMap<$HookSequence> = HookDefinitionMap<$HookSequence>, - $Result = unknown, - $Name extends $HookSequence[number] = $HookSequence[number], - $Options extends InterceptorOptions = InterceptorOptions, -> = PublicHook< - // (<$$Input extends $HookMap[$Name]['input']>( - (( - input?: - & { - input?: $HookMap[$Name]['input'] - } - & (keyof $HookMap[$Name]['slots'] extends never ? {} : { using?: SlotInputify<$HookMap[$Name]['slots']> }), - ) => InferPublicHookReturn<$HookSequence, $HookMap, $Result, $Name, $Options>), - $HookMap[$Name]['input'] -> - -// & (<$$Input extends $HookMap[$Name]['input']>( -// input?: // InferHookPrivateInput<$HookSequence,$HookMap,$Name> -// { -// input?: $$Input -// } & (keyof $HookMap[$Name]['slots'] extends never ? {} : { using?: SlotInputify<$HookMap[$Name]['slots']> }), -// ) => PublicHookReturn<$HookSequence, $HookMap, $Result, $Name, $Options>) -// & { -// [hookSymbol]: HookSymbol -// input: $HookMap[$Name]['input'] -// } - -// dprint-ignore -type InferPublicHookReturn< - $HookSequence extends HookSequence, - $HookMap extends HookDefinitionMap<$HookSequence> = HookDefinitionMap<$HookSequence>, - $Result = unknown, - $Name extends $HookSequence[number] = $HookSequence[number], - $Options extends InterceptorOptions = InterceptorOptions, -> = Promise< - | ($Options['retrying'] extends true ? Error : never) - | (IsLastValue<$Name, $HookSequence> extends true - ? $Result - : { - [$NameNext in FindValueAfter<$Name, $HookSequence>]: InferPublicHook< - $HookSequence, - $HookMap, - $Result, - $NameNext - > - } - ) -> - -type SlotInputify<$Slots extends Record any>> = { - [K in keyof $Slots]?: SlotInput<$Slots[K]> -} - -type SlotInput any> = (...args: Parameters) => ReturnType | undefined - -const hookSymbol = Symbol(`hook`) - -type HookSymbol = typeof hookSymbol - -export type SomePublicHookEnvelope = { - [name: string]: PublicHook -} - -export const createPublicHook = <$OriginalInput, $Fn extends PublicHookFn>( - originalInput: $OriginalInput, - fn: $Fn, -): PublicHook<$Fn> => { - // ): $Hook & { input: $OriginalInput } => { - // @ts-expect-error - fn.input = originalInput - // @ts-expect-error - return fn -} - -export type PublicHook< - $Fn extends PublicHookFn = PublicHookFn, - $OriginalInput extends object = object, // Exclude[0], undefined>['input'], -> = - & $Fn - & { - [hookSymbol]: HookSymbol - // todo the result is unknown, but if we build a EndEnvelope, then we can work with this type more logically and put it here. - // E.g. adding `| unknown` would destroy the knowledge of hook envelope case - // todo this is not strictly true, it could also be the final result - input: $OriginalInput - } - -type PublicHookFn = (input?: HookPublicInput) => any - -interface HookPublicInput { - input?: any - using?: any -} diff --git a/src/lib/anyware/run/getEntrypoint.ts b/src/lib/anyware/run/getEntrypoint.ts index ca4af60c7..54fde57b5 100644 --- a/src/lib/anyware/run/getEntrypoint.ts +++ b/src/lib/anyware/run/getEntrypoint.ts @@ -1,7 +1,8 @@ import { analyzeFunction } from '../../analyze-function.js' import { ContextualError } from '../../errors/ContextualError.js' -import type { HookName } from '../hook/definition.js' -import type { NonRetryingInterceptorInput } from '../Interceptor.js' +import type { ExecutableStepRuntime } from '../ExecutableStep.js' +import type { NonRetryingInterceptorInput } from '../Interceptor/Interceptor.js' +import type { PipelineExecutable } from '../Pipeline/Executable.js' export class ErrorAnywareInterceptorEntrypoint extends ContextualError< 'ErrorGraffleInterceptorEntryHook', @@ -9,7 +10,7 @@ export class ErrorAnywareInterceptorEntrypoint extends ContextualError< > { // todo add to context: parameters value parsed and raw constructor(context: { issue: InterceptorEntryHookIssue }) { - super(`Interceptor must destructure the first parameter passed to it and select exactly one entrypoint.`, context) + super(`Interceptor must destructure the first parameter passed to it and select exactly one step.`, context) } } @@ -19,14 +20,16 @@ export const InterceptorEntryHookIssue = { notDestructured: `notDestructured`, destructuredWithoutEntryHook: `destructuredWithoutEntryHook`, multipleDestructuredHookNames: `multipleDestructuredHookNames`, + invalidDestructuredHookNames: `invalidDestructuredHookNames`, } as const export type InterceptorEntryHookIssue = typeof InterceptorEntryHookIssue[keyof typeof InterceptorEntryHookIssue] -export const getEntrypoint = ( - hookNames: readonly string[], +export const getEntryStep = ( + pipeline: PipelineExecutable, interceptor: NonRetryingInterceptorInput, -): ErrorAnywareInterceptorEntrypoint | HookName => { +): ErrorAnywareInterceptorEntrypoint | ExecutableStepRuntime => { + const stepsIndex = pipeline.stepsIndex const x = analyzeFunction(interceptor) if (x.parameters.length > 1) { return new ErrorAnywareInterceptorEntrypoint({ issue: InterceptorEntryHookIssue.multipleParameters }) @@ -41,16 +44,22 @@ export const getEntrypoint = ( if (p.names.length === 0) { return new ErrorAnywareInterceptorEntrypoint({ issue: InterceptorEntryHookIssue.destructuredWithoutEntryHook }) } - const hooks = p.names.filter(_ => hookNames.includes(_ as any)) + const steps = p.names.filter(_ => stepsIndex.has(_)) - if (hooks.length > 1) { + if (steps.length > 1) { return new ErrorAnywareInterceptorEntrypoint({ issue: InterceptorEntryHookIssue.multipleDestructuredHookNames }) } - const hook = hooks[0] - if (!hook) { + const stepName = steps[0] + + if (!stepName) { + return new ErrorAnywareInterceptorEntrypoint({ issue: InterceptorEntryHookIssue.invalidDestructuredHookNames }) + } + + const step = stepsIndex.get(stepName) + if (!step) { return new ErrorAnywareInterceptorEntrypoint({ issue: InterceptorEntryHookIssue.destructuredWithoutEntryHook }) } else { - return hook + return step } } } diff --git a/src/lib/anyware/__.test.ts b/src/lib/anyware/run/run.test.ts similarity index 60% rename from src/lib/anyware/__.test.ts rename to src/lib/anyware/run/run.test.ts index b37aec591..9681c38b1 100644 --- a/src/lib/anyware/__.test.ts +++ b/src/lib/anyware/run/run.test.ts @@ -1,16 +1,17 @@ /* eslint-disable */ -import { describe, expect, test, vi } from 'vitest' -import { Errors } from '../errors/__.js' -import type { ContextualError } from '../errors/ContextualError.js' -import { Anyware } from './__.js' -import { core, createHook, initialInput, oops, run, runWithOptions } from './__.test-helpers.js' -import { createRetryingInterceptor } from './Interceptor.js' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { Errors } from '../../errors/__.js' +import type { ContextualError } from '../../errors/ContextualError.js' +import { Pipeline } from '../_.js' +import { initialInput2, oops, run, runRetrying, runWithOptions, stepsIndex } from '../__.test-helpers.js' +import { type ResultSuccess, successfulResult } from '../Pipeline/Result.js' +import { Step } from '../Step.js' -describe(`no extensions`, () => { +describe(`no interceptors`, () => { test(`passthrough to implementation`, async () => { const result = await run() - expect(result).toEqual({ value: `initial+a+b` }) + expect(result).toEqual({ value: { value: `initial+a+b` } }) }) }) @@ -22,17 +23,17 @@ describe(`one extension`, () => { await b({ input: b.input }) return 0 }), - ).toEqual(0) - expect(core.hooks.a.run.mock.calls[0]).toMatchObject([{ input: { value: `initial` } }]) - expect(core.hooks.a.run).toHaveBeenCalled() - expect(core.hooks.b.run).toHaveBeenCalled() + ).toEqual(successfulResult(0)) + expect(stepsIndex.a.run.mock.calls[0]).toMatchObject([{ input: { value: `initial` } }]) + expect(stepsIndex.a.run).toHaveBeenCalled() + expect(stepsIndex.b.run).toHaveBeenCalled() }) test('can call hook with no input, making the original input be used', () => { expect( run(async ({ a }) => { return await a() }), - ).resolves.toEqual({ value: 'initial+a+b' }) + ).resolves.toEqual({ value: { value: 'initial+a+b' } }) // todo why doesn't this work? // expect(core.hooks.a).toHaveBeenCalled() // expect(core.hooks.b).toHaveBeenCalled() @@ -44,9 +45,9 @@ describe(`one extension`, () => { await run(({ a }) => { return a.input }), - ).toEqual({ value: `initial` }) - expect(core.hooks.a.run).not.toHaveBeenCalled() - expect(core.hooks.b.run).not.toHaveBeenCalled() + ).toEqual({ value: { value: `initial` } }) + expect(stepsIndex.a.run).not.toHaveBeenCalled() + expect(stepsIndex.b.run).not.toHaveBeenCalled() }) test(`at start, return own result`, async () => { expect( @@ -54,9 +55,9 @@ describe(`one extension`, () => { await run(({ a }) => { return 0 }), - ).toEqual(0) - expect(core.hooks.a.run).not.toHaveBeenCalled() - expect(core.hooks.b.run).not.toHaveBeenCalled() + ).toEqual({ value: 0 }) + expect(stepsIndex.a.run).not.toHaveBeenCalled() + expect(stepsIndex.b.run).not.toHaveBeenCalled() }) test(`after first hook, return own result`, async () => { expect( @@ -64,8 +65,8 @@ describe(`one extension`, () => { const { b } = await a({ input: a.input }) return b.input.value + `+x` }), - ).toEqual(`initial+a+x`) - expect(core.hooks.b.run).not.toHaveBeenCalled() + ).toEqual(successfulResult(`initial+a+x`)) + expect(stepsIndex.b.run).not.toHaveBeenCalled() }) }) describe(`can partially apply`, () => { @@ -74,14 +75,14 @@ describe(`one extension`, () => { await run(async ({ a }) => { return await a({ input: { value: a.input.value + `+ext` } }) }), - ).toEqual({ value: `initial+ext+a+b` }) + ).toEqual(successfulResult({ value: `initial+ext+a+b` })) }) test(`only second hook`, async () => { expect( await run(async ({ b }) => { return await b({ input: { value: b.input.value + `+ext` } }) }), - ).toEqual({ value: `initial+a+ext+b` }) + ).toEqual(successfulResult({ value: `initial+a+ext+b` })) }) test(`only second hook + end`, async () => { expect( @@ -89,26 +90,33 @@ describe(`one extension`, () => { const result = await b({ input: { value: b.input.value + `+ext` } }) return result.value + `+end` }), - ).toEqual(`initial+a+ext+b+end`) + ).toEqual(successfulResult(`initial+a+ext+b+end`)) }) }) }) -describe(`two extensions`, () => { - const run = runWithOptions({ entrypointSelectionMode: `optional` }) +describe(`two interceptors`, () => { + let run: ReturnType['run'] + let stepIndex: ReturnType['stepsIndex'] + + beforeEach(() => { + const info = runWithOptions({ entrypointSelectionMode: `optional` }) + run = info.run + stepIndex = info.stepsIndex + }) test(`first can short-circuit`, async () => { - const ex1 = () => 1 - const ex2 = vi.fn().mockImplementation(() => 2) - expect(await run(ex1, ex2)).toEqual(1) - expect(ex2).not.toHaveBeenCalled() - expect(core.hooks.a.run).not.toHaveBeenCalled() - expect(core.hooks.b.run).not.toHaveBeenCalled() + const i1 = () => 1 + const i2 = vi.fn().mockImplementation(() => 2) + expect(await run(i1, i2)).toEqual(successfulResult(1)) + expect(i2).not.toHaveBeenCalled() + expect(stepsIndex.a.run).not.toHaveBeenCalled() + expect(stepsIndex.b.run).not.toHaveBeenCalled() }) test(`each can adjust first hook then passthrough`, async () => { const ex1 = ({ a }: any) => a({ input: { value: a.input.value + `+ex1` } }) const ex2 = ({ a }: any) => a({ input: { value: a.input.value + `+ex2` } }) - expect(await run(ex1, ex2)).toEqual({ value: `initial+ex1+ex2+a+b` }) + expect(await run(ex1, ex2)).toEqual(successfulResult({ value: `initial+ex1+ex2+a+b` })) }) test(`each can adjust each hook`, async () => { @@ -120,7 +128,7 @@ describe(`two extensions`, () => { const { b } = await a({ input: { value: a.input.value + `+ex2` } }) return await b({ input: { value: b.input.value + `+ex2` } }) } - expect(await run(ex1, ex2)).toEqual({ value: `initial+ex1+ex2+a+ex1+ex2+b` }) + expect(await run(ex1, ex2)).toEqual(successfulResult({ value: `initial+ex1+ex2+a+ex1+ex2+b` })) }) test(`second can skip hook a`, async () => { @@ -131,37 +139,36 @@ describe(`two extensions`, () => { const ex2 = async ({ b }: any) => { return await b({ input: { value: b.input.value + `+ex2` } }) } - expect(await run(ex1, ex2)).toEqual({ value: `initial+ex1+a+ex1+ex2+b` }) + expect(await run(ex1, ex2)).toEqual(successfulResult({ value: `initial+ex1+a+ex1+ex2+b` })) }) - test(`second can short-circuit before hook a`, async () => { + test(`second can short-circuit before step a`, async () => { let ex1AfterA = false - const ex1 = async ({ a }: any) => { + const i1 = async ({ a }: any) => { const { b } = await a({ value: a.input.value + `+ex1` }) ex1AfterA = true } - const ex2 = async ({ a }: any) => { - return 2 - } - expect(await run(ex1, ex2)).toEqual(2) + const i2 = async ({ a }: any) => 2 + + expect(await run(i1, i2)).toEqual(successfulResult(2)) expect(ex1AfterA).toBe(false) - expect(core.hooks.a.run).not.toHaveBeenCalled() - expect(core.hooks.b.run).not.toHaveBeenCalled() + expect(stepsIndex.a.run).not.toHaveBeenCalled() + expect(stepsIndex.b.run).not.toHaveBeenCalled() }) - test(`second can short-circuit after hook a`, async () => { + test(`second can short-circuit after step a`, async () => { let ex1AfterB = false - const ex1 = async ({ a }: any) => { + const i1 = async ({ a }: any) => { const { b } = await a({ input: { value: a.input.value + `+ex1` } }) await b({ value: b.input.value + `+ex1` }) ex1AfterB = true } - const ex2 = async ({ a }: any) => { + const i2 = async ({ a }: any) => { await a({ value: a.input.value + `+ex2` }) return 2 } - expect(await run(ex1, ex2)).toEqual(2) + expect(await run(i1, i2)).toEqual(successfulResult(2)) expect(ex1AfterB).toBe(false) - expect(core.hooks.a.run).toHaveBeenCalledOnce() - expect(core.hooks.b.run).not.toHaveBeenCalled() + expect(stepsIndex.a.run).toHaveBeenCalledOnce() + expect(stepsIndex.b.run).not.toHaveBeenCalled() }) }) @@ -207,8 +214,8 @@ describe(`errors`, () => { `) }) - test(`if implementation fails, without extensions, result is the error`, async () => { - core.hooks.a.run.mockReset().mockRejectedValueOnce(oops) + test(`if implementation fails, without interceptors, result is the error`, async () => { + stepsIndex.a.run.mockReset().mockRejectedValueOnce(oops) const result = await run() as ContextualError expect({ result, @@ -225,7 +232,7 @@ describe(`errors`, () => { } `) }) - test('calling a hook twice leads to clear error', async () => { + test('calling a step trigger twice leads to clear error', async () => { let neverRan = true const result = await run(async ({ a }) => { await a() @@ -247,97 +254,92 @@ describe(`errors`, () => { describe('certain errors can be configured to be re-thrown without wrapping error', () => { class SpecialError1 extends Error {} class SpecialError2 extends Error {} - const a = createHook({ - slots: {}, - run: ({ input }: { slots: object; input: { throws: Error } }) => { + const stepA = Step.createWithInput<{ throws: Error }>()({ + name: 'a', + run: ({ input }) => { if (input.throws) throw input.throws }, }) test('via passthroughErrorInstanceOf (one)', async () => { - const anyware = Anyware.create<['a'], Anyware.HookDefinitionMap<['a']>>({ - hookNamesOrderedBySequence: [`a`], - hooks: { a }, + const builder = Pipeline.create<{ throws: Error }>({ passthroughErrorInstanceOf: [SpecialError1], - }) + }).step(stepA).done() + // dprint-ignore - expect(anyware.run({ initialInput: { throws: new Error('oops') }, interceptors: [] })).resolves.toBeInstanceOf(Errors.ContextualError) + expect(Pipeline.run(builder, { initialInput: { throws: new Error('oops') }, interceptors: [] })).resolves.toBeInstanceOf(Errors.ContextualError) // dprint-ignore - expect(anyware.run({ initialInput: { throws: new SpecialError1('oops') }, interceptors: [] })).resolves.toBeInstanceOf(SpecialError1) + expect(Pipeline.run(builder, { initialInput: { throws: new SpecialError1('oops') }, interceptors: [] })).resolves.toBeInstanceOf(SpecialError1) }) test('via passthroughErrorInstanceOf (multiple)', async () => { - const anyware = Anyware.create<['a'], Anyware.HookDefinitionMap<['a']>>({ - hookNamesOrderedBySequence: [`a`], - hooks: { a }, + const builder = Pipeline.create<{ throws: Error }>({ passthroughErrorInstanceOf: [SpecialError1, SpecialError2], - }) + }).step(stepA).done() // dprint-ignore - expect(anyware.run({ initialInput: { throws: new Error('oops') }, interceptors: [] })).resolves.toBeInstanceOf(Errors.ContextualError) + expect(Pipeline.run(builder, { initialInput: { throws: new Error('oops') }, interceptors: [] })).resolves.toBeInstanceOf(Errors.ContextualError) // dprint-ignore - expect(anyware.run({ initialInput: { throws: new SpecialError2('oops') }, interceptors: [] })).resolves.toBeInstanceOf(SpecialError2) + expect(Pipeline.run(builder, { initialInput: { throws: new SpecialError2('oops') }, interceptors: [] })).resolves.toBeInstanceOf(SpecialError2) }) test('via passthroughWith', async () => { - const anyware = Anyware.create<['a'], Anyware.HookDefinitionMap<['a']>>({ - hookNamesOrderedBySequence: [`a`], - hooks: { a }, + const builder = Pipeline.create<{ throws: Error }>({ // todo type-safe hook name according to values passed to constructor // todo type-tests on signal { hookName, source, error } passthroughErrorWith: (signal) => { return signal.error instanceof SpecialError1 }, - }) + }).step(stepA).done() // dprint-ignore - expect(anyware.run({ initialInput: { throws: new Error('oops') }, interceptors: [] })).resolves.toBeInstanceOf(Errors.ContextualError) + expect(Pipeline.run(builder, { initialInput: { throws: new Error('oops') }, interceptors: [] })).resolves.toBeInstanceOf(Errors.ContextualError) // dprint-ignore - expect(anyware.run({ initialInput: { throws: new SpecialError1('oops') }, interceptors: [] })).resolves.toBeInstanceOf(SpecialError1) + expect(Pipeline.run(builder, { initialInput: { throws: new SpecialError1('oops') }, interceptors: [] })).resolves.toBeInstanceOf(SpecialError1) }) }) }) describe('retrying extension', () => { test('if hook fails, extension can retry, then short-circuit', async () => { - core.hooks.a.run.mockReset().mockRejectedValueOnce(oops).mockResolvedValueOnce(1) - const result = await run(createRetryingInterceptor(async function foo({ a }) { + stepsIndex.a.run.mockReset().mockRejectedValueOnce(oops).mockResolvedValueOnce(1) + const result = await runRetrying(async function foo({ a }) { const result1 = await a() expect(result1).toEqual(oops) const result2 = await a() expect(typeof result2.b).toEqual('function') expect(result2.b.input).toEqual(1) return result2.b.input - })) - expect(result).toEqual(1) + }) + expect(result).toEqual(successfulResult(1)) }) describe('errors', () => { - test('not last extension', async () => { - const result = await run( - createRetryingInterceptor(async function foo({ a }) { - return a() - }), - async function bar({ a }) { - return a() - }, - ) - expect(result).toMatchInlineSnapshot(`[ContextualError: Only the last extension can retry hooks.]`) - expect((result as Errors.ContextualError).context).toMatchInlineSnapshot(` - { - "extensionsAfter": [ - { - "name": "bar", - }, - ], - } - `) - }) + // test('not last extension', async () => { + // const result = await run( + // createRetryingInterceptor(async function foo({ a }) { + // return a() + // }), + // async function bar({ a }) { + // return a() + // }, + // ) + // expect(result).toMatchInlineSnapshot(`[ContextualError: Only the last extension can retry hooks.]`) + // expect((result as Errors.ContextualError).context).toMatchInlineSnapshot(` + // { + // "extensionsAfter": [ + // { + // "name": "bar", + // }, + // ], + // } + // `) + // }) test('call hook twice even though it succeeded the first time', async () => { let neverRan = true - const result = await run( - createRetryingInterceptor(async function foo({ a }) { + const result = await runRetrying( + async function foo({ a }) { const result1 = await a() expect('b' in result1).toBe(true) await a() // <-- Extension bug here under test. neverRan = false - }), + }, ) expect(neverRan).toBe(true) expect(result).toMatchInlineSnapshot( @@ -362,22 +364,22 @@ describe('retrying extension', () => { describe('slots', () => { test('have defaults that are called by default', async () => { await run() - expect(core.hooks.a.slots.append.mock.calls[0]).toMatchObject(['a']) - expect(core.hooks.b.slots.append.mock.calls[0]).toMatchObject(['b']) + expect(stepsIndex.a.slots.append.mock.calls[0]).toMatchObject(['a']) + expect(stepsIndex.b.slots.append.mock.calls[0]).toMatchObject(['b']) }) test('extension can provide own function to slot on just one of a set of hooks', async () => { const result = await run(async ({ a }) => { return a({ using: { append: () => 'x' } }) }) - expect(core.hooks.a.slots.append).not.toBeCalled() - expect(core.hooks.b.slots.append.mock.calls[0]).toMatchObject(['b']) - expect(result).toEqual({ value: 'initial+x+b' }) + expect(stepsIndex.a.slots.append).not.toBeCalled() + expect(stepsIndex.b.slots.append.mock.calls[0]).toMatchObject(['b']) + expect(result).toEqual(successfulResult({ value: 'initial+x+b' })) }) test('extension can provide own functions to slots on multiple of a set of hooks', async () => { const result = await run(async ({ a }) => { return a({ using: { append: () => 'x', appendExtra: () => '+x2' } }) }) - expect(result).toEqual({ value: 'initial+x+x2+b' }) + expect(result).toEqual(successfulResult({ value: 'initial+x+x2+b' })) }) // todo hook with two slots test('two extensions can each provide own function to same slot on just one of a set of hooks, and the later one wins', async () => { @@ -385,9 +387,9 @@ describe('slots', () => { const { b } = await a({ using: { append: () => 'x' } }) return b({ using: { append: () => 'y' } }) }) - expect(core.hooks.a.slots.append).not.toBeCalled() - expect(core.hooks.b.slots.append).not.toBeCalled() - expect(result).toEqual({ value: 'initial+x+y' }) + expect(stepsIndex.a.slots.append).not.toBeCalled() + expect(stepsIndex.b.slots.append).not.toBeCalled() + expect(result).toEqual(successfulResult({ value: 'initial+x+y' })) }) }) @@ -396,8 +398,8 @@ describe('private hook parameter - previous', () => { await run(async ({ a }) => { return a() }) - expect(core.hooks.a.run.mock.calls[0]?.[0].previous).toEqual({}) - expect(core.hooks.b.run.mock.calls[0]?.[0].previous).toEqual({ a: { input: initialInput } }) + expect(stepsIndex.a.run.mock.calls[0]?.[0].previous).toEqual({}) + expect(stepsIndex.b.run.mock.calls[0]?.[0].previous).toEqual({ a: { input: initialInput2 } }) }) test('contains the final input actually passed to the hook', async () => { @@ -405,7 +407,7 @@ describe('private hook parameter - previous', () => { await run(async ({ a }) => { return a({ input: customInput }) }) - expect(core.hooks.a.run.mock.calls[0]?.[0].previous).toEqual({}) - expect(core.hooks.b.run.mock.calls[0]?.[0].previous).toEqual({ a: { input: customInput } }) + expect(stepsIndex.a.run.mock.calls[0]?.[0].previous).toEqual({}) + expect(stepsIndex.b.run.mock.calls[0]?.[0].previous).toEqual({ a: { input: customInput } }) }) }) diff --git a/src/lib/anyware/run/run.ts b/src/lib/anyware/run/run.ts new file mode 100644 index 000000000..b64742e65 --- /dev/null +++ b/src/lib/anyware/run/run.ts @@ -0,0 +1,22 @@ +import { createRunner } from '../_.js' +import type { Pipeline } from '../Pipeline/__.js' +import type { Result } from '../Pipeline/Result.js' +import type { Params } from './runner.js' + +type Run = < + $Pipeline extends Pipeline.PipelineExecutable, + $Params extends Params<$Pipeline>, +>( + pipeline: $Pipeline, + params?: $Params, +) => Promise< + Result<$Pipeline['output']> +> + +/** + * todo + */ +export const run: Run = async (pipeline, params) => { + const runner = createRunner(pipeline) + return await runner(params as any) as any +} diff --git a/src/lib/anyware/run/runPipeline.ts b/src/lib/anyware/run/runPipeline.ts index c923bdb25..7ed39208a 100644 --- a/src/lib/anyware/run/runPipeline.ts +++ b/src/lib/anyware/run/runPipeline.ts @@ -1,51 +1,58 @@ import type { Errors } from '../../errors/__.js' import { ContextualError } from '../../errors/ContextualError.js' import { casesExhausted, createDeferred, debug } from '../../prelude.js' -import type { HookResult, HookResultErrorAsync } from '../hook/private.js' -import type { InterceptorGeneric } from '../Interceptor.js' -import type { Pipeline } from '../Pipeline/Pipeline.js' +import type { InterceptorGeneric } from '../Interceptor/Interceptor.js' +import type { PipelineExecutable } from '../Pipeline/Executable.js' +import type { Step } from '../Step.js' +import type { StepResult, StepResultErrorAsync } from '../StepResult.js' import { createResultEnvelope } from './resultEnvelope.js' import type { ResultEnvelop } from './resultEnvelope.js' -import { runHook } from './runHook.js' +import { runStep } from './runStep.js' export const defaultFunctionName = `anonymous` -interface Input { - pipeline: Pipeline - hookNamesOrderedBySequence: readonly string[] - originalInputOrResult: unknown - interceptorsStack: readonly InterceptorGeneric[] - asyncErrorDeferred: HookResultErrorAsync - previous: object -} - export const runPipeline = async ( - { pipeline, hookNamesOrderedBySequence, originalInputOrResult, interceptorsStack, asyncErrorDeferred, previous }: - Input, + { + pipeline, + stepsToProcess, + originalInputOrResult, + interceptorsStack, + asyncErrorDeferred, + previousStepsCompleted, + }: { + pipeline: PipelineExecutable + stepsToProcess: readonly Step[] + originalInputOrResult: unknown + interceptorsStack: readonly InterceptorGeneric[] + asyncErrorDeferred: StepResultErrorAsync + previousStepsCompleted: object + }, ): Promise => { - const [hookName, ...hookNamesRest] = hookNamesOrderedBySequence + const [stepToProcess, ...stepsRestToProcess] = stepsToProcess - if (!hookName) { + if (!stepToProcess) { debug(`pipeline: ending`) const result = await runPipelineEnd({ interceptorsStack, result: originalInputOrResult }) debug(`pipeline: returning`) return createResultEnvelope(result) } - debug(`hook ${hookName}: start`) + debug(`hook ${stepToProcess.name}: start`) - const done = createDeferred({ strict: false }) + const done = createDeferred({ strict: false }) - void runHook({ + // We do not await the step runner here. + // Instead we work with a deferred passed to it. + void runStep({ pipeline, - name: hookName, + name: stepToProcess.name, done: done.resolve, inputOriginalOrFromExtension: originalInputOrResult as object, - previous, + previousStepsCompleted, interceptorsStack, asyncErrorDeferred, customSlots: {}, - nextExtensionsStack: [], + nextInterceptorsStack: [], }) const signal = await Promise.race( @@ -55,18 +62,18 @@ export const runPipeline = async ( switch (signal.type) { case `completed`: { const { result, effectiveInput, nextExtensionsStack } = signal - const nextPrevious = { - ...previous, - [hookName]: { + const nextPreviousStepsCompleted = { + ...previousStepsCompleted, + [stepToProcess.name]: { input: effectiveInput, }, } return await runPipeline({ pipeline, - hookNamesOrderedBySequence: hookNamesRest, + stepsToProcess: stepsRestToProcess, originalInputOrResult: result, interceptorsStack: nextExtensionsStack, - previous: nextPrevious, + previousStepsCompleted: nextPreviousStepsCompleted, asyncErrorDeferred, }) } @@ -79,16 +86,12 @@ export const runPipeline = async ( debug(`signal: error`) signal - if (pipeline.passthroughErrorWith) { - if (pipeline.passthroughErrorWith(signal)) { - return signal.error as any // todo change return type to be unknown since this function could permit anything? - } + if (pipeline.config.passthroughErrorWith?.(signal)) { + return signal.error as any // todo change return type to be unknown since this function could permit anything? } - if (pipeline.passthroughErrorInstanceOf) { - if (pipeline.passthroughErrorInstanceOf.some(_ => signal.error instanceof _)) { - return signal.error as any // todo change return type to include object... given this instanceof permits that? - } + if (pipeline.config.passthroughErrorInstanceOf.some(_ => signal.error instanceof _)) { + return signal.error as any // todo change return type to include object... given this instanceof permits that? } const wasAsync = asyncErrorDeferred.isResolved() diff --git a/src/lib/anyware/run/runHook.ts b/src/lib/anyware/run/runStep.ts similarity index 74% rename from src/lib/anyware/run/runHook.ts rename to src/lib/anyware/run/runStep.ts index deb93574b..c9744d9f6 100644 --- a/src/lib/anyware/run/runHook.ts +++ b/src/lib/anyware/run/runStep.ts @@ -1,59 +1,59 @@ import { Errors } from '../../errors/__.js' import { casesExhausted, createDeferred, debugSub, errorFromMaybeError } from '../../prelude.js' -import type { HookResult, HookResultErrorAsync, Slots } from '../hook/private.js' -import { createPublicHook, type SomePublicHookEnvelope } from '../hook/public.js' -import type { InterceptorGeneric } from '../Interceptor.js' -import type { Pipeline } from '../Pipeline/Pipeline.js' +import type { InterceptorGeneric } from '../Interceptor/Interceptor.js' +import type { PipelineExecutable } from '../Pipeline/Executable.js' +import type { Step } from '../Step.js' +import type { StepResult, StepResultErrorAsync } from '../StepResult.js' +import { StepTrigger } from '../StepTrigger.js' +import type { StepTriggerEnvelope } from '../StepTriggerEnvelope.js' import type { ResultEnvelop } from './resultEnvelope.js' -type HookDoneResolver = (input: HookResult) => void - -interface Input { - pipeline: Pipeline - name: string - done: HookDoneResolver - inputOriginalOrFromExtension: object - /** - * Information about previous hook executions, like what their input was. - */ - previous: object - customSlots: Slots - /** - * The extensions that are at this hook awaiting. - */ - interceptorsStack: readonly InterceptorGeneric[] - /** - * The extensions that have advanced past this hook, to their next hook, - * and are now awaiting. - * - * @remarks every extension popped off the stack is added here (except those - * that short-circuit the pipeline or enter passthrough mode). - */ - nextExtensionsStack: readonly InterceptorGeneric[] - asyncErrorDeferred: HookResultErrorAsync -} +type HookDoneResolver = (input: StepResult) => void const createExecutableChunk = <$Extension extends InterceptorGeneric>(extension: $Extension) => ({ ...extension, - currentChunk: createDeferred(), + currentChunk: createDeferred(), }) -export const runHook = async ( +export const runStep = async ( { pipeline, name, done, inputOriginalOrFromExtension, - previous, + previousStepsCompleted, interceptorsStack, - nextExtensionsStack, + nextInterceptorsStack, asyncErrorDeferred, customSlots, - }: Input, + }: { + pipeline: PipelineExecutable + name: string + done: HookDoneResolver + inputOriginalOrFromExtension: object + /** + * Information about previous hook executions, like what their input was. + */ + previousStepsCompleted: object + customSlots: Step.Slots + /** + * The extensions that are at this hook awaiting. + */ + interceptorsStack: readonly InterceptorGeneric[] + /** + * The extensions that have advanced past this hook, to their next hook, + * and are now awaiting. + * + * @remarks every extension popped off the stack is added here (except those + * that short-circuit the pipeline or enter passthrough mode). + */ + nextInterceptorsStack: readonly InterceptorGeneric[] + asyncErrorDeferred: StepResultErrorAsync + }, ) => { - const debugHook = debugSub(`hook ${name}:`) + const debugHook = debugSub(`step ${name}:`) - debugHook(`advance to next extension`) + debugHook(`advance to next interceptor`) const [extension, ...extensionsStackRest] = interceptorsStack const isLastExtension = extensionsStackRest.length === 0 @@ -81,7 +81,7 @@ export const runHook = async ( debugExtension(`start`) let hookFailed = false - const hook = createPublicHook(inputOriginalOrFromExtension, (extensionInput) => { + const trigger = StepTrigger.create(inputOriginalOrFromExtension, (extensionInput) => { debugExtension(`extension calls this hook`, extensionInput) const inputResolved = extensionInput?.input ?? inputOriginalOrFromExtension @@ -125,42 +125,42 @@ export const runHook = async ( } else { debugExtension(`execute branch: retry`) const extensionRetry = createExecutableChunk(extension) - void runHook({ + void runStep({ pipeline, name, done, - previous, + previousStepsCompleted, inputOriginalOrFromExtension, asyncErrorDeferred, interceptorsStack: [extensionRetry], - nextExtensionsStack, + nextInterceptorsStack, customSlots: customSlotsResolved, }) return extensionRetry.currentChunk.promise.then(async (envelope) => { - const envelop_ = envelope as SomePublicHookEnvelope // todo ... better way? + const envelop_ = envelope as StepTriggerEnvelope // todo ... better way? const hook = envelop_[name] // as (params:{input:object;previous:object;using:Slots}) => if (!hook) throw new Error(`Hook not found in envelope: ${name}`) // todo use inputResolved ? const result = await hook({ ...extensionInput, input: extensionInput?.input ?? inputOriginalOrFromExtension, - }) as Promise + }) as Promise return result }) } } else { const extensionWithNextChunk = createExecutableChunk(extension) - const nextNextHookStack = [...nextExtensionsStack, extensionWithNextChunk] // tempting to mutate here but simpler to think about as copy. + const nextNextHookStack = [...nextInterceptorsStack, extensionWithNextChunk] // tempting to mutate here but simpler to think about as copy. hookInvokedDeferred.resolve(true) - void runHook({ + void runStep({ pipeline, name, done, - previous, + previousStepsCompleted, asyncErrorDeferred, inputOriginalOrFromExtension: inputResolved, interceptorsStack: extensionsStackRest, - nextExtensionsStack: nextNextHookStack, + nextInterceptorsStack: nextNextHookStack, customSlots: customSlotsResolved, }) @@ -177,9 +177,8 @@ export const runHook = async ( // The extension is resumed. It is responsible for calling the next hook. debugExtension(`advance with envelope`) - // @ts-expect-error fixme - const envelope: SomeHookEnvelope = { - [name]: hook, + const envelope: StepTriggerEnvelope = { + [name]: trigger, } extension.currentChunk.resolve(envelope) @@ -207,15 +206,15 @@ export const runHook = async ( case `extensionReturned`: { debugExtension(`extension returned`) if (result === envelope) { - void runHook({ + void runStep({ pipeline, name, done, - previous, + previousStepsCompleted, inputOriginalOrFromExtension, asyncErrorDeferred, interceptorsStack: extensionsStackRest, - nextExtensionsStack, + nextInterceptorsStack, customSlots, }) } else { @@ -248,27 +247,27 @@ export const runHook = async ( throw casesExhausted(branch) } } /* reached core for this hook */ else { - debugHook(`no more extensions to advance, run implementation`) + debugHook(`no more interceptors to advance, run implementation`) - const implementation = pipeline.hooks[name] + const implementation = pipeline.stepsIndex.get(name) if (!implementation) { - throw new Errors.ContextualError(`Implementation not found for hook name ${name}`, { hookName: name }) + throw new Errors.ContextualError(`Implementation not found for step name ${name}`, { hookName: name }) } let result try { const slotsResolved = { - ...implementation.slots as Slots, // todo is this cast needed, can we Slots type the property? + ...implementation.slots, ...customSlots, } result = await implementation.run({ input: inputOriginalOrFromExtension, slots: slotsResolved, - previous: previous, + previous: previousStepsCompleted, }) } catch (error) { debugHook(`implementation error`) - const lastExtension = nextExtensionsStack[nextExtensionsStack.length - 1] + const lastExtension = nextInterceptorsStack[nextInterceptorsStack.length - 1] if (lastExtension && lastExtension.retrying) { lastExtension.currentChunk.resolve(errorFromMaybeError(error)) } else { @@ -285,7 +284,7 @@ export const runHook = async ( type: `completed`, result, effectiveInput: inputOriginalOrFromExtension, - nextExtensionsStack: nextExtensionsStack, + nextExtensionsStack: nextInterceptorsStack, }) } } diff --git a/src/lib/anyware/run/runner.ts b/src/lib/anyware/run/runner.ts index 80aa062e2..d241bad3a 100644 --- a/src/lib/anyware/run/runner.ts +++ b/src/lib/anyware/run/runner.ts @@ -2,72 +2,55 @@ import { partitionAndAggregateErrors } from '../../errors/_.js' import { Errors } from '../../errors/__.js' import { createDeferred } from '../../prelude.js' import { casesExhausted } from '../../prelude.js' -import type { Private } from '../../private.js' -import type { HookName } from '../hook/definition.js' -import type { HookResultErrorExtension } from '../hook/private.js' -import type { SomePublicHookEnvelope } from '../hook/public.js' -import { createRetryingInterceptor, type Interceptor, type InterceptorInput } from '../Interceptor.js' -import type { Pipeline } from '../Pipeline/Pipeline.js' -import { getEntrypoint } from './getEntrypoint.js' +import type { PipelineSpec } from '../_.js' +import { + createRetryingInterceptor, + type InterceptorInput, + type NonRetryingInterceptorInput, +} from '../Interceptor/Interceptor.js' +import type { Pipeline } from '../Pipeline/__.js' +import { type InferResultFromSpec, successfulResult } from '../Pipeline/Result.js' +import type { Step } from '../Step.js' +import type { StepResultErrorExtension } from '../StepResult.js' +import type { StepTriggerEnvelope } from '../StepTriggerEnvelope.js' +import { getEntryStep } from './getEntrypoint.js' import { runPipeline } from './runPipeline.js' -export type Runner<$Pipeline extends Pipeline = Pipeline> = ( - { initialInput, interceptors, options }: { - initialInput: GetInitialPipelineInput<$Pipeline> - interceptors: Interceptor<$Pipeline>[] - retryingInterceptor?: Interceptor<$Pipeline, { retrying: true }> - options?: Options - }, -) => Promise['result'] | Errors.ContextualError> - -const resolveOptions = (options?: Options): Config => { - return { - entrypointSelectionMode: options?.entrypointSelectionMode ?? `required`, - } -} - -export type Options = { - /** - * @defaultValue `true` - */ - entrypointSelectionMode?: 'optional' | 'required' | 'off' +export interface Params<$Pipeline extends PipelineSpec = PipelineSpec> { + initialInput: $Pipeline['input'] + interceptors: NonRetryingInterceptorInput[] + retryingInterceptor?: NonRetryingInterceptorInput } -type Config = Required - export const createRunner = - <$Pipeline extends Pipeline>(pipeline: $Pipeline): Runner<$Pipeline> => - async ({ initialInput, interceptors, options, retryingInterceptor }) => { + <$Pipeline extends Pipeline.PipelineExecutable>(pipeline: $Pipeline) => + async (params?: Params<$Pipeline>): Promise> => { + const { initialInput, interceptors = [], retryingInterceptor } = params ?? {} + const interceptors_ = retryingInterceptor ? [...interceptors, createRetryingInterceptor(retryingInterceptor)] : interceptors - const initialHookStackAndErrors = interceptors_.map(extension => - toInternalInterceptor(pipeline, resolveOptions(options), extension) - ) + const initialHookStackAndErrors = interceptors_.map(extension => toInternalInterceptor(pipeline, extension)) const [initialHookStack, error] = partitionAndAggregateErrors(initialHookStackAndErrors) if (error) return error - const asyncErrorDeferred = createDeferred({ strict: false }) + const asyncErrorDeferred = createDeferred({ strict: false }) const result = await runPipeline({ pipeline, - hookNamesOrderedBySequence: pipeline.hookNamesOrderedBySequence, + stepsToProcess: pipeline.steps, originalInputOrResult: initialInput, // todo fix any interceptorsStack: initialHookStack as any, asyncErrorDeferred, - previous: {}, + previousStepsCompleted: {}, }) - if (result instanceof Error) return result + if (result instanceof Error) return result as any - return result.result as any + return successfulResult(result.result) } -type GetInitialPipelineInput<$Pipeline extends Pipeline> = Private.Get< - $Pipeline ->['hookMap'][Private.Get<$Pipeline>['hookSequence'][0]]['input'] - -const toInternalInterceptor = (pipeline: Pipeline, config: Config, interceptor: InterceptorInput) => { - const currentChunk = createDeferred() +const toInternalInterceptor = (pipeline: Pipeline.PipelineExecutable, interceptor: InterceptorInput) => { + const currentChunk = createDeferred() const body = createDeferred() const extensionRun = typeof interceptor === `function` ? interceptor : interceptor.run const retrying = typeof interceptor === `function` ? false : interceptor.retrying @@ -80,39 +63,39 @@ const toInternalInterceptor = (pipeline: Pipeline, config: Config, interceptor: } } - const extensionName = extensionRun.name || `anonymous` + const interceptorName = extensionRun.name || `anonymous` - switch (config.entrypointSelectionMode) { + switch (pipeline.config.entrypointSelectionMode) { case `off`: { void currentChunk.promise.then(applyBody) return { - name: extensionName, - entrypoint: pipeline.hookNamesOrderedBySequence[0], // todo non-empty-array data structure + name: interceptorName, + entrypoint: pipeline.steps[0]?.name, body, currentChunk, } } case `optional`: case `required`: { - const entrypoint = getEntrypoint(pipeline.hookNamesOrderedBySequence, extensionRun) - if (entrypoint instanceof Error) { - if (config.entrypointSelectionMode === `required`) { - return entrypoint + const entryStep = getEntryStep(pipeline, extensionRun) + if (entryStep instanceof Error) { + if (pipeline.config.entrypointSelectionMode === `required`) { + return entryStep } else { void currentChunk.promise.then(applyBody) return { - name: extensionName, - entrypoint: pipeline.hookNamesOrderedBySequence[0], // todo non-empty-array data structure + name: interceptorName, + entrypoint: pipeline.steps[0]?.name, body, currentChunk, } } } - const hooksBeforeEntrypoint: HookName[] = [] - for (const hookName of pipeline.hookNamesOrderedBySequence) { - if (hookName === entrypoint) break - hooksBeforeEntrypoint.push(hookName) + const hooksBeforeEntrypoint: Step.Name[] = [] + for (const step of pipeline.steps) { + if (step === entryStep) break + hooksBeforeEntrypoint.push(step.name) } const passthroughs = hooksBeforeEntrypoint.map((hookName) => createPassthrough(hookName)) @@ -124,18 +107,18 @@ const toInternalInterceptor = (pipeline: Pipeline, config: Config, interceptor: return { retrying, - name: extensionName, - entrypoint, + name: interceptorName, + entryStep, body, currentChunk, } } default: - throw casesExhausted(config.entrypointSelectionMode) + throw casesExhausted(pipeline.config.entrypointSelectionMode) } } -const createPassthrough = (hookName: string) => async (hookEnvelope: SomePublicHookEnvelope) => { +const createPassthrough = (hookName: string) => async (hookEnvelope: StepTriggerEnvelope) => { const hook = hookEnvelope[hookName] if (!hook) { throw new Errors.ContextualError(`Hook not found in hook envelope`, { hookName }) diff --git a/src/lib/builder/Definition.ts b/src/lib/builder/Definition.ts index 00dacd7b5..df74707e9 100644 --- a/src/lib/builder/Definition.ts +++ b/src/lib/builder/Definition.ts @@ -1,5 +1,5 @@ import type { Simplify } from 'type-fest' -import type { AssertExtends, mergeArrayOfObjects } from '../prelude.js' +import type { AssertExtends, Tuple } from '../prelude.js' import type { Private } from '../private.js' import type { TypeFunction } from '../type-function/__.js' import type { Context, Extension } from './Extension.js' @@ -55,11 +55,11 @@ export type MaterializeGeneric<$Chain_ extends Definition_> = Private.Add< { chain: $Chain_, - context: mergeArrayOfObjects< + context: Tuple.IntersectItems< MaterializeExtensionsGenericContext<$Chain_['extensions']> > }, - mergeArrayOfObjects< + Tuple.IntersectItems< MaterializeExtensionsGeneric<$Chain_, $Chain_['extensions']> > > @@ -83,11 +83,11 @@ export type MaterializeSpecific<$Chain_ extends Definition_> = Private.Add< { chain: $Chain_, - context: mergeArrayOfObjects< + context: Tuple.IntersectItems< MaterializeExtensionsInitialContext<$Chain_['extensions']> > }, - mergeArrayOfObjects< + Tuple.IntersectItems< MaterializeExtensionsInitial<$Chain_, $Chain_['extensions']> > > @@ -113,7 +113,7 @@ export type MaterializeWithNewContext<$Chain_ extends Definition_, $Context exte chain: $Chain_, context: $Context }, - mergeArrayOfObjects< + Tuple.IntersectItems< MaterializeExtensionsWithNewState< $Chain_, $Context, diff --git a/src/lib/config-manager/ConfigManager.test-d.ts b/src/lib/config-manager/ConfigManager.test-d.ts index bcd2a77c2..c3acd1248 100644 --- a/src/lib/config-manager/ConfigManager.test-d.ts +++ b/src/lib/config-manager/ConfigManager.test-d.ts @@ -30,7 +30,7 @@ assertEqual, never>() assertEqual, never>() assertEqual, { a: { b: never } }>() -assertEqual, { a: { b: 2 }; b: string }>() +assertEqual, { a: { b: 2 }; b: string }>() // dprint-ignore { diff --git a/src/lib/config-manager/ConfigManager.ts b/src/lib/config-manager/ConfigManager.ts index 4aa1a7fa5..9cd0c74a7 100644 --- a/src/lib/config-manager/ConfigManager.ts +++ b/src/lib/config-manager/ConfigManager.ts @@ -2,6 +2,14 @@ import type { IsUnknown, PartialDeep, Simplify } from 'type-fest' import { isDate } from 'util/types' import { type ExcludeUndefined, type GuardedType, isAnyFunction, isNonNullObject } from '../prelude.js' +// dprint-ignore +export type OrDefault<$Value, $Default> = + // When no value has been passed in because the property is optional, + // then the inferred type is unknown. + IsUnknown<$Value> extends true ? $Default : + $Value extends undefined ? $Default : + $Value + // dprint-ignore export type MergeDefaults<$Defaults extends object, $Input extends undefined | object, $CustomScalars> = $Input extends undefined @@ -111,14 +119,6 @@ export type AppendOptional<$Array extends any[], $Value> = $Value extends undefi export type GetAtPathOrDefault<$Obj, $Path extends Path, $Default> = OrDefault, $Default> -// dprint-ignore -export type OrDefault<$Value, $Default> = - // When no value has been passed in, because the property is optional, - // then the inferred type is unknown. - IsUnknown<$Value> extends true ? $Default : - $Value extends undefined ? $Default : - $Value - // dprint-ignore export type GetOptional<$Value, $Path extends [...string[]]> = $Value extends undefined ? undefined : @@ -146,7 +146,7 @@ export type SetMany<$Obj extends object, $Sets extends [Path, any][]> = > : never -export type SetKey<$Obj extends object, $Prop extends keyof $Obj, $Type extends $Obj[$Prop]> = +export type SetOneKey<$Obj extends object, $Prop extends keyof $Obj, $Type extends $Obj[$Prop]> = & Omit<$Obj, $Prop> & { [_ in $Prop]: $Type } diff --git a/src/lib/prelude.test-d.ts b/src/lib/prelude.test-d.ts index a856db74d..8b44f0b32 100644 --- a/src/lib/prelude.test-d.ts +++ b/src/lib/prelude.test-d.ts @@ -1,5 +1,5 @@ import { assertEqual } from './assert-equal.js' -import type { OmitKeysWithPrefix, ToParameters } from './prelude.js' +import { type OmitKeysWithPrefix, type ToParameters, type Tuple } from './prelude.js' // dprint-ignore { @@ -13,4 +13,22 @@ assertEqual , []>() assertEqual , [{ a:1; b?:2 }]>() assertEqual , [{ a?:1; b?:2 }]|[]>() +// Tuple.* + +assertEqual, 3>() +// @ts-expect-error +GetLastValue<[]> + +assertEqual, [1, 2, 3]>() +assertEqual, [3]>() +assertEqual, []>() + +assertEqual, 2>() +assertEqual, undefined>() + +assertEqual, 2>() +assertEqual, false>() + +assertEqual, { a: { name: 'a' }, b: { name: 'b' } }>() +assertEqual, {}>() } diff --git a/src/lib/prelude.ts b/src/lib/prelude.ts index 579b9962e..4e1d051c8 100644 --- a/src/lib/prelude.ts +++ b/src/lib/prelude.ts @@ -1,4 +1,5 @@ import type { HasRequiredKeys, IsAny, IsEmptyObject, IsNever, IsUnknown, Simplify } from 'type-fest' +import type { ConfigManager } from './config-manager/__.js' /* eslint-disable */ export type RemoveIndex = { @@ -253,9 +254,9 @@ export type MaybePromise = T | Promise export const capitalizeFirstLetter = (string: string) => string.charAt(0).toUpperCase() + string.slice(1) -export type SomeAsyncFunction = (...args: unknown[]) => Promise +export type SomeAsyncFunction = (...args: any[]) => Promise -export type SomeFunction = (...args: unknown[]) => MaybePromise +export type SomeFunction = (...args: any[]) => MaybePromise export type Deferred = { promise: Promise @@ -298,7 +299,84 @@ export const debugSub = (...args: any[]) => (...subArgs: any[]) => { debug(...args, ...subArgs) } -export type PlusOneUpToTen = n extends 0 ? 1 +export namespace Tuple { + export type NonEmpty = [any, ...any[]] + // dprint-ignore + export type IntersectItems<$Items extends readonly any[]> = + $Items extends [infer $First, ...infer $Rest extends any[]] + ? $First & IntersectItems<$Rest> + : {} + + // dprint-ignore + export type ToIndexByObjectKey<$Items extends readonly object[], $Key extends keyof $Items[number]> = + Simplify< + IntersectItems<{ + [$Index in keyof $Items]: + $Key extends keyof $Items[$Index] + ? { + [_ in $Items[$Index][$Key] & string]: $Items[$Index] + } + : never + }> + > + + // dprint-ignore + export type GetAtNextIndex<$Items extends readonly any[], $Index extends NumberLiteral> = + $Items[PlusOne<$Index>] + + // dprint-ignore + export type GetNextIndexOr<$Items extends readonly any[], $Index extends number, $Or> = + ConfigManager.OrDefault, $Or> + + // dprint-ignore + export type DropUntilIndex<$Items extends readonly any[], $Index extends NumberLiteral> = + $Index extends 0 ? $Items : + $Items extends readonly [infer _, ...infer $Rest] ? DropUntilIndex<$Rest, MinusOne<$Index>> : + [] + + export type IndexPlusOne<$Index extends NumberLiteral> = PlusOne<$Index> + + export type GetLastValue = T[MinusOne] + + export type IsLastValue = value extends GetLastValue ? true + : false + + // dprint-ignore + export type findIndexForValue = + findIndexForValue_ + + // dprint-ignore + type findIndexForValue_ = + value extends list[i] + ? i + : findIndexForValue_> + + export type FindValueAfter = + list[PlusOne>] + + // dprint-ignore + export type TakeValuesBefore<$Value, $List extends AnyReadOnly> = + $List extends readonly [infer $ListFirst, ...infer $ListRest] + ? $Value extends $ListFirst + ? [] + : [$ListFirst, ...TakeValuesBefore<$Value, $ListRest>] + : [] + + export type FindValueAfterOr = ConfigManager.OrDefault< + list[PlusOne>], + orValue + > + + type AnyReadOnly = readonly any[] + + type AnyReadOnlyListNonEmpty = readonly [any, ...any[]] +} + +type NumberLiteral = number | `${number}` + +// dprint-ignore +export type PlusOne = + n extends 0 ? 1 : n extends 1 ? 2 : n extends 2 ? 3 : n extends 3 ? 4 @@ -310,7 +388,9 @@ export type PlusOneUpToTen = n extends 0 ? 1 : n extends 9 ? 10 : never -export type MinusOneUpToTen = n extends 10 ? 9 +// dprint-ignore +export type MinusOne = + n extends 10 ? 9 : n extends 9 ? 8 : n extends 8 ? 7 : n extends 7 ? 6 @@ -322,41 +402,6 @@ export type MinusOneUpToTen = n extends 10 ? 9 : n extends 1 ? 0 : never -// dprint-ignore -export type findIndexForValue = - findIndexForValue_ - -// dprint-ignore -type findIndexForValue_ = - value extends list[i] - ? i - : findIndexForValue_> - -export type FindValueAfter = - list[PlusOneUpToTen>] - -// dprint-ignore -export type TakeValuesBefore<$Value, $List extends AnyReadOnlyList> = - $List extends readonly [infer $ListFirst, ...infer $ListRest] - ? $Value extends $ListFirst - ? [] - : [$ListFirst, ...TakeValuesBefore<$Value, $ListRest>] - : [] - -type AnyReadOnlyListNonEmpty = readonly [any, ...any[]] -type AnyReadOnlyList = readonly [...any[]] - -export type ValueOr = value extends undefined ? orValue : value - -export type FindValueAfterOr = ValueOr< - list[PlusOneUpToTen>], - orValue -> - -export type GetLastValue = T[MinusOneUpToTen] - -export type IsLastValue = value extends GetLastValue ? true : false - export type Include = T extends U ? T : never export const partitionErrors = (array: T[]): [Exclude[], Include[]] => { @@ -458,10 +503,6 @@ export const shallowMergeDefaults = <$Defaults extends object, $Input extends ob return merged as any } -export type mergeArrayOfObjects = T extends [infer $First, ...infer $Rest extends any[]] - ? $First & mergeArrayOfObjects<$Rest> - : {} - export const identityProxy = new Proxy({}, { get: () => (value: unknown) => value, }) @@ -637,7 +678,7 @@ export type SimplifyExcept<$ExcludeType, $Type> = ? $Type : {[TypeKey in keyof $Type]: $Type[TypeKey]} -export const any = undefined as any +export const _ = undefined as any // dprint-ignore export type ToParameters<$Params extends object | undefined> = @@ -661,3 +702,13 @@ export const isAbortError = (error: any): error is DOMException & { name: 'Abort // todo look for an open issue with JSDOM to link here, is this just artifact of JSDOM or is it a real issue that happens in browsers? || (error instanceof Error && error.message.startsWith(`AbortError:`)) } + +export namespace Func { + // dprint-ignore + export type AppendAwaitedReturnType<$F, $ReturnTypeToAdd> = + $F extends (...args: infer $Args) => infer $Output + ? $Output extends Promise + ? (...args: $Args) => Promise | $ReturnTypeToAdd> + : (...args: $Args) => $Output | $ReturnTypeToAdd + : never +} diff --git a/src/requestPipeline/RequestPipeline.ts b/src/requestPipeline/RequestPipeline.ts index d3ef75929..9a7bd4532 100644 --- a/src/requestPipeline/RequestPipeline.ts +++ b/src/requestPipeline/RequestPipeline.ts @@ -1,63 +1,54 @@ -import type { GraffleExecutionResultVar } from '../client/handleOutput.js' +import type { FormattedExecutionResult, GraphQLSchema } from 'graphql' +import type { Context } from '../client/context.js' +import type { GraffleExecutionResultEnvelope } from '../client/handleOutput.js' import type { Config } from '../client/Settings/Config.js' import { MethodMode, type MethodModeGetReads } from '../client/transportHttp/request.js' +import type { MethodModePost } from '../client/transportHttp/request.js' import { Anyware } from '../lib/anyware/__.js' import type { Grafaid } from '../lib/grafaid/__.js' import { OperationTypeToAccessKind, print } from '../lib/grafaid/document.js' import { execute } from '../lib/grafaid/execute.js' // todo -import { - getRequestEncodeSearchParameters, - getRequestHeadersRec, - parseExecutionResult, - postRequestEncodeBody, - postRequestHeadersRec, -} from '../lib/grafaid/http/http.js' +import { getRequestEncodeSearchParameters, postRequestEncodeBody } from '../lib/grafaid/http/http.js' +import { getRequestHeadersRec, parseExecutionResult, postRequestHeadersRec } from '../lib/grafaid/http/http.js' import { normalizeRequestToNode } from '../lib/grafaid/request.js' import { mergeRequestInit, searchParamsAppendAll } from '../lib/http.js' -import { casesExhausted, isAbortError, isString } from '../lib/prelude.js' +import type { httpMethodGet, httpMethodPost } from '../lib/http.js' +import { casesExhausted, isAbortError, isString, type MaybePromise } from '../lib/prelude.js' import { Transport } from '../types/Transport.js' +import type { TransportHttp, TransportMemory } from '../types/Transport.js' import { decodeResultData } from './CustomScalars/decode.js' import { encodeRequestVariables } from './CustomScalars/encode.js' -import { - type CoreExchangeGetRequest, - type CoreExchangePostRequest, - type HookMap, - hookNamesOrderedBySequence, - type HookSequence, -} from './types.js' - -export type RequestPipeline<$Config extends Config = Config> = Anyware.Pipeline< - HookSequence, - HookMap<$Config>, - GraffleExecutionResultVar<$Config> -> - -export const RequestPipeline = Anyware.Pipeline.create({ - // If core errors caused by an abort error then raise it as a direct error. - // This is an expected possible error. Possible when user cancels a request. - passthroughErrorWith: (signal) => { - // todo have anyware propagate the input that was passed to the hook that failed. - // it will give us a bit more confidence that we're only allowing this abort error for fetch requests stuff - // context.config.transport.type === Transport.http - return signal.hookName === `exchange` && isAbortError(signal.error) - }, - hookNamesOrderedBySequence, - hooks: { - encode: ({ input }) => { - const sddm = input.state.schemaMap - const scalars = input.state.scalars.map - if (sddm) { - const request = normalizeRequestToNode(input.request) - - // We will mutate query. Assign it back to input for it to be carried forward. - input.request.query = request.query - - encodeRequestVariables({ sddm, scalars, request }) - } - return input +export const requestPipeline = Anyware.Pipeline + .createWithSpec({ + options: { + // If core errors caused by an abort error then raise it as a direct error. + // This is an expected possible error. Possible when user cancels a request. + passthroughErrorWith: (signal) => { + // todo have anyware propagate the input that was passed to the hook that failed. + // it will give us a bit more confidence that we're only allowing this abort error for fetch requests stuff + // context.config.transport.type === Transport.http + return signal.hookName === `exchange` && isAbortError(signal.error) + }, }, - pack: { + steps: [{ + name: `encode`, + run: ({ input }): requestPipeline.Steps.HookDefPack['input'] => { + const sddm = input.state.schemaMap + const scalars = input.state.scalars.map + if (sddm) { + const request = normalizeRequestToNode(input.request) + + // We will mutate query. Assign it back to input for it to be carried forward. + input.request.query = request.query + + encodeRequestVariables({ sddm, scalars, request }) + } + + return input + }, + }, { + name: `pack`, slots: { searchParams: getRequestEncodeSearchParameters, body: postRequestEncodeBody, @@ -86,7 +77,7 @@ export const RequestPipeline = Anyware.Pipeline.create fetch(request), + fetch: (requestInfo: RequestInfo): MaybePromise => fetch(requestInfo), }, run: async ({ input, slots }) => { switch (input.transportType) { @@ -157,58 +148,183 @@ export const RequestPipeline = Anyware.Pipeline.create { - switch (input.transportType) { - case `http`: { - // todo 1 if response is missing header of content length then .json() hangs forever. - // firstly consider a timeout, secondly, if response is malformed, then don't even run .json() - // todo 2 if response is e.g. 404 with no json body, then an error is thrown because json parse cannot work, not gracefully handled here - const json = await input.response.json() as object - const result = parseExecutionResult(json) - return { - ...input, - result, + }, { + name: `unpack`, + run: async ({ input }) => { + switch (input.transportType) { + case `http`: { + // todo 1 if response is missing header of content length then .json() hangs forever. + // firstly consider a timeout, secondly, if response is malformed, then don't even run .json() + // todo 2 if response is e.g. 404 with no json body, then an error is thrown because json parse cannot work, not gracefully handled here + const json = await input.response.json() as object + const result = parseExecutionResult(json) + return { + ...input, + result, + } } - } - case `memory`: { - return { - ...input, - result: input.result, + case `memory`: { + return { + ...input, + result: input.result, + } } + default: + throw casesExhausted(input) } - default: - throw casesExhausted(input) + }, + }, { + name: `decode`, + run: ({ input, previous }) => { + // If there has been an error and we definitely don't have any data, such as when + // giving an operation name that doesn't match any in the document, + // then don't attempt to decode. + const isError = !input.result.data && (input.result.errors?.length ?? 0) > 0 + if (input.state.schemaMap && !isError) { + decodeResultData({ + sddm: input.state.schemaMap, + request: normalizeRequestToNode(previous.pack.input.request), + data: input.result.data, + scalars: input.state.scalars.map, + }) + } + + const result = input.transportType === `http` + ? { + ...input.result, + response: input.response, + } + : input.result + + return result + }, + }], + }) + +export namespace requestPipeline { + export type ResultFailure = Anyware.Pipeline.ResultFailure + // | Errors.ContextualError + // Possible from http transport fetch with abort controller. + // | DOMException + + export type Result<$Config extends Config = Config> = Anyware.Pipeline.InferResultFromSpec> + + export type Spec<$Config extends Config = Config> = Anyware.PipelineSpecFromSteps<[ + Steps.HookDefEncode<$Config>, + Steps.HookDefPack<$Config>, + Steps.HookDefExchange<$Config>, + Steps.HookDefUnpack<$Config>, + Steps.HookDefDecode<$Config>, + ]> + + export namespace Steps { + export interface HookInputBase { + state: Context + } + + // dprint-ignore + + type TransportInput<$Config extends Config, $HttpProperties = {}, $MemoryProperties = {}> = + | ( + TransportHttp extends $Config['transport']['type'] + ? ({ + transportType: TransportHttp + url: string | URL + } & $HttpProperties) + : never + ) + | ( + TransportMemory extends $Config['transport']['type'] + ? ({ + transportType: TransportMemory + schema: GraphQLSchema + } & $MemoryProperties) + : never + ) + + // --------------------------- + + export type HookDefEncode<$Config extends Config = Config> = { + name: `encode` + input: + & { request: Grafaid.RequestAnalyzedInput } + & HookInputBase + & TransportInput<$Config> + } + + export type HookDefPack<$Config extends Config = Config> = { + name: `pack` + input: + & HookInputBase + & TransportInput< + $Config, + // todo why is headers here but not other http request properties? + { headers?: HeadersInit } + > + & { request: Grafaid.RequestAnalyzedInput } + slots: { + /** + * When request will be sent using GET this slot is called to create the value that will be used for the HTTP Search Parameters. + */ + searchParams: getRequestEncodeSearchParameters + /** + * When request will be sent using POST this slot is called to create the value that will be used for the HTTP body. + */ + body: postRequestEncodeBody } - }, - decode: ({ input, previous }) => { - // If there has been an error and we definitely don't have any data, such as when - // giving an operation name that doesn't match any in the document, - // then don't attempt to decode. - const isError = !input.result.data && (input.result.errors?.length ?? 0) > 0 - if (input.state.schemaMap && !isError) { - decodeResultData({ - sddm: input.state.schemaMap, - request: normalizeRequestToNode(previous.pack.input.request), - data: input.result.data, - scalars: input.state.scalars.map, - }) + } + + export type HookDefExchange<$Config extends Config> = { + name: `exchange` + slots: { + fetch: (request: Request) => Response | Promise } + input: + & HookInputBase + & TransportInput< + $Config, + { request: CoreExchangePostRequest | CoreExchangeGetRequest; headers?: HeadersInit }, + { request: Grafaid.HTTP.RequestConfig } + > + } - const result = input.transportType === `http` - ? { - ...input.result, - response: input.response, - } - : input.result + export type HookDefUnpack<$Config extends Config> = { + name: `unpack` + input: + & HookInputBase + & TransportInput< + $Config, + { response: Response }, + { result: FormattedExecutionResult } + > + } - return result - }, - }, - // todo expose return handling as part of the pipeline? - // would be nice but alone would not yield type safe return handling - // still, while figuring the type story out, might be a useful escape hatch for some cases... -}) -// todo -// .use(transportHttp) -// RequestPipeline + export type HookDefDecode<$Config extends Config> = { + name: `decode` + input: + & HookInputBase + & TransportInput< + $Config, + { response: Response } + > + & { result: FormattedExecutionResult } + output: GraffleExecutionResultEnvelope<$Config> + } + + /** + * An extension of {@link RequestInit} that adds a required `url` property and makes `body` required. + */ + export type CoreExchangePostRequest = Omit & { + methodMode: MethodModePost | MethodModeGetReads + method: httpMethodPost + url: string | URL // todo URL for config and string only for input. Requires anyware to allow different types for input and existing config. + body: BodyInit + } + + export type CoreExchangeGetRequest = Omit & { + methodMode: MethodModeGetReads + method: httpMethodGet + url: string | URL + } + } +} diff --git a/src/requestPipeline/_.ts b/src/requestPipeline/_.ts deleted file mode 100644 index 4ae5b6598..000000000 --- a/src/requestPipeline/_.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './RequestPipeline.js' -export * as Hooks from './types.js' diff --git a/src/requestPipeline/__.ts b/src/requestPipeline/__.ts index 35dd2529c..86baf9a24 100644 --- a/src/requestPipeline/__.ts +++ b/src/requestPipeline/__.ts @@ -1 +1 @@ -export * as RequestPipeline from './_.js' +export * from './RequestPipeline.js' diff --git a/src/requestPipeline/types.ts b/src/requestPipeline/types.ts deleted file mode 100644 index 080e9e2c1..000000000 --- a/src/requestPipeline/types.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { FormattedExecutionResult, GraphQLSchema } from 'graphql' -import type { Context } from '../client/context.js' -import type { Config } from '../client/Settings/Config.js' -import type { MethodModeGetReads, MethodModePost } from '../client/transportHttp/request.js' -import type { Grafaid } from '../lib/grafaid/__.js' -import type { getRequestEncodeSearchParameters, postRequestEncodeBody } from '../lib/grafaid/http/http.js' -import type { httpMethodGet, httpMethodPost } from '../lib/http.js' -import type { TransportHttp, TransportMemory } from '../types/Transport.js' - -interface HookInputBase { - state: Context -} - -// dprint-ignore - -type TransportInput<$Config extends Config, $HttpProperties = {}, $MemoryProperties = {}> = - | ( - TransportHttp extends $Config['transport']['type'] - ? ({ - transportType: TransportHttp - url: string | URL - } & $HttpProperties) - : never - ) - | ( - TransportMemory extends $Config['transport']['type'] - ? ({ - transportType: TransportMemory - schema: GraphQLSchema - } & $MemoryProperties) - : never - ) - -// --------------------------- - -export type HookDefEncode<$Config extends Config> = { - input: - & { request: Grafaid.RequestAnalyzedInput } - & HookInputBase - & TransportInput<$Config> -} - -export type HookDefPack<$Config extends Config> = { - input: - & HookInputBase - & TransportInput< - $Config, - // todo why is headers here but not other http request properties? - { headers?: HeadersInit } - > - & { request: Grafaid.RequestAnalyzedInput } - slots: { - /** - * When request will be sent using GET this slot is called to create the value that will be used for the HTTP Search Parameters. - */ - searchParams: getRequestEncodeSearchParameters - /** - * When request will be sent using POST this slot is called to create the value that will be used for the HTTP body. - */ - body: postRequestEncodeBody - } -} - -export type HookDefExchange<$Config extends Config> = { - slots: { - fetch: (request: Request) => Response | Promise - } - input: - & HookInputBase - & TransportInput< - $Config, - { request: CoreExchangePostRequest | CoreExchangeGetRequest; headers?: HeadersInit }, - { request: Grafaid.HTTP.RequestConfig } - > -} - -export type HookDefUnpack<$Config extends Config> = { - input: - & HookInputBase - & TransportInput< - $Config, - { response: Response }, - { result: FormattedExecutionResult } - > -} - -export type HookDefDecode<$Config extends Config> = { - input: - & HookInputBase - & TransportInput< - $Config, - { response: Response } - > - & { result: FormattedExecutionResult } -} - -export type HookMap<$Config extends Config = Config> = { - encode: HookDefEncode<$Config> - pack: HookDefPack<$Config> - exchange: HookDefExchange<$Config> - unpack: HookDefUnpack<$Config> - decode: HookDefDecode<$Config> -} - -export const hookNamesOrderedBySequence = [`encode`, `pack`, `exchange`, `unpack`, `decode`] as const - -export type HookSequence = typeof hookNamesOrderedBySequence - -/** - * An extension of {@link RequestInit} that adds a required `url` property and makes `body` required. - */ -export type CoreExchangePostRequest = Omit & { - methodMode: MethodModePost | MethodModeGetReads - method: httpMethodPost - url: string | URL // todo URL for config and string only for input. Requires anyware to allow different types for input and existing config. - body: BodyInit -} - -export type CoreExchangeGetRequest = Omit & { - methodMode: MethodModeGetReads - method: httpMethodGet - url: string | URL -} diff --git a/tests/_/SpyExtension.ts b/tests/_/SpyExtension.ts index 691390fd3..107d74713 100644 --- a/tests/_/SpyExtension.ts +++ b/tests/_/SpyExtension.ts @@ -1,17 +1,17 @@ import { beforeEach } from 'vitest' import { createExtension } from '../../src/entrypoints/main.js' import type { Config } from '../../src/entrypoints/utilities-for-generated.js' -import type { HookDefEncode, HookDefExchange, HookDefPack } from '../../src/requestPipeline/types.js' +import type { requestPipeline } from '../../src/requestPipeline/__.js' interface SpyData { encode: { - input: HookDefEncode['input'] | null + input: requestPipeline.Steps.HookDefEncode['input'] | null } pack: { - input: HookDefPack['input'] | null + input: requestPipeline.Steps.HookDefPack['input'] | null } exchange: { - input: HookDefExchange['input'] | null + input: requestPipeline.Steps.HookDefExchange['input'] | null } } diff --git a/tests/_/helpers.ts b/tests/_/helpers.ts index 572c43862..02738bbe1 100644 --- a/tests/_/helpers.ts +++ b/tests/_/helpers.ts @@ -5,6 +5,7 @@ import * as Path from 'node:path' import type { Mock } from 'vitest' import { test as testBase, vi } from 'vitest' import type { Client } from '../../src/client/client.js' +import type { TransportConfigHttp, TransportConfigMemory } from '../../src/client/Settings/Config.js' import { Graffle } from '../../src/entrypoints/main.js' import type { Context, SchemaDrivenDataMap } from '../../src/entrypoints/utilities-for-generated.js' import type { ConfigManager } from '../../src/lib/config-manager/__.js' @@ -37,8 +38,32 @@ interface Fixtures { fetch: Mock<(request: Request) => Promise> pokemonService: SchemaService graffle: Client - kitchenSink: Client> - kitchenSinkHttp: Client> + kitchenSink: Client< + ConfigManager.SetProperties< + Context, + { + name: `default` + schemaMap: SchemaDrivenDataMap + config: { + output: Context['config']['output'] + transport: TransportConfigMemory + } + } + > + > + kitchenSinkHttp: Client< + ConfigManager.SetProperties< + Context, + { + name: `default` + schemaMap: SchemaDrivenDataMap + config: { + output: Context['config']['output'] + transport: TransportConfigHttp + } + } + > + > kitchenSinkData: typeof db project: Project } @@ -130,12 +155,13 @@ export const test = testBase.extend({ }, kitchenSink: async ({ fetch: _ }, use) => { const kitchenSink = KitchenSink.create({ schema: kitchenSinkSchema }) - // @ts-expect-error fixme + // kitchenSink.anyware(async ({ encode }) => { + // encode({ input: {}}) + // }) await use(kitchenSink) }, kitchenSinkHttp: async ({ fetch: _ }, use) => { const kitchenSink = KitchenSink.create({ schema: `https://foo.io/api/graphql` }) - // @ts-expect-error fixme await use(kitchenSink) }, kitchenSinkData: async ({}, use) => { // eslint-disable-line