From b67769df0bd6439801b5c72f30bdff70934c73d3 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Fri, 17 May 2024 23:18:26 -0400 Subject: [PATCH 01/30] feat(graffle): extension system --- src/layers/0_functions/request.ts | 6 +- src/layers/0_functions/requestOrExecute.ts | 23 ----- src/layers/5_client/client.customize.test.ts | 96 +++++++++++++++++++ src/layers/5_client/client.ts | 47 +++++++-- src/layers/5_client/extension/getEntryHook.ts | 54 +++++++++++ src/layers/5_client/extension/runStack.ts | 17 ++++ src/layers/5_client/extension/types.ts | 16 ++++ src/layers/5_client/requestOrExecute.ts | 41 ++++++++ src/lib/analyzeFunction.ts | 62 ++++++++++++ src/lib/prelude.ts | 2 + 10 files changed, 329 insertions(+), 35 deletions(-) delete mode 100644 src/layers/0_functions/requestOrExecute.ts create mode 100644 src/layers/5_client/client.customize.test.ts create mode 100644 src/layers/5_client/extension/getEntryHook.ts create mode 100644 src/layers/5_client/extension/runStack.ts create mode 100644 src/layers/5_client/extension/types.ts create mode 100644 src/layers/5_client/requestOrExecute.ts create mode 100644 src/lib/analyzeFunction.ts diff --git a/src/layers/0_functions/request.ts b/src/layers/0_functions/request.ts index 7b5421e9..2a6c5169 100644 --- a/src/layers/0_functions/request.ts +++ b/src/layers/0_functions/request.ts @@ -6,15 +6,17 @@ import type { BaseInput } from './types.js' export type URLInput = URL | string -interface Input extends BaseInput { +export interface NetworkRequestInput extends BaseInput { url: URLInput headers?: HeadersInit } +export type NetworkRequest = (input: NetworkRequestInput) => Promise + /** * @see https://graphql.github.io/graphql-over-http/draft/ */ -export const request = async (input: Input): Promise => { +export const request: NetworkRequest = async (input) => { const documentEncoded = typeof input.document === `string` ? input.document : print(input.document) const body = { diff --git a/src/layers/0_functions/requestOrExecute.ts b/src/layers/0_functions/requestOrExecute.ts deleted file mode 100644 index b4978ac7..00000000 --- a/src/layers/0_functions/requestOrExecute.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ExecutionResult, GraphQLSchema } from 'graphql' -import { execute } from './execute.js' -import type { URLInput } from './request.js' -import { request } from './request.js' -import type { BaseInput } from './types.js' - -export type SchemaInput = URLInput | GraphQLSchema - -export interface Input extends BaseInput { - schema: SchemaInput -} - -export const requestOrExecute = async ( - input: Input, -): Promise => { - const { schema, ...baseInput } = input - - if (schema instanceof URL || typeof schema === `string`) { - return await request({ url: schema, ...baseInput }) - } - - return await execute({ schema, ...baseInput }) -} diff --git a/src/layers/5_client/client.customize.test.ts b/src/layers/5_client/client.customize.test.ts new file mode 100644 index 00000000..c3586fc6 --- /dev/null +++ b/src/layers/5_client/client.customize.test.ts @@ -0,0 +1,96 @@ +/* eslint-disable */ +import { describe, expect, expectTypeOf } from 'vitest' +import { db } from '../../../tests/_/db.js' +import { createResponse, test } from '../../../tests/_/helpers.js' +import { Graffle } from '../../../tests/_/schema/generated/__.js' +import { ErrorGraffleExtensionEntryHook } from './extension/getEntryHook.js' +import { Extension, NetworkRequestHook } from './extension/types.js' + +const client = Graffle.create({ schema: 'https://foo', returnMode: 'dataAndErrors' }) + +describe('invalid destructuring cases', () => { + const make = async (extension: Extension) => + (await client.extend(extension).query.id()) as any as ErrorGraffleExtensionEntryHook + + test('noParameters', async () => { + const result = await make(async ({}) => {}) + expect(result).toMatchInlineSnapshot( + `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, + ) + expect(result.context).toMatchInlineSnapshot(` + { + "issue": "noParameters", + } + `) + }) + test('noParameters', async () => { + const result = await make(async () => {}) + expect(result).toMatchInlineSnapshot( + `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, + ) + expect(result.context).toMatchInlineSnapshot(` + { + "issue": "noParameters", + } + `) + }) + test('destructuredWithoutEntryHook', async () => { + // @ts-expect-error + const result = await make(async ({ send2 }) => {}) + expect(result).toMatchInlineSnapshot( + `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, + ) + expect(result.context).toMatchInlineSnapshot(` + { + "issue": "destructuredWithoutEntryHook", + } + `) + }) + test('multipleParameters', async () => { + // @ts-expect-error + const result = await make(async ({ send }, two) => {}) + expect(result).toMatchInlineSnapshot( + `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, + ) + expect(result.context).toMatchInlineSnapshot(` + { + "issue": "multipleParameters", + } + `) + }) + test('notDestructured', async () => { + const result = await make(async (_) => {}) + expect(result).toMatchInlineSnapshot( + `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, + ) + expect(result.context).toMatchInlineSnapshot(` + { + "issue": "notDestructured", + } + `) + }) + // todo once we have multiple hooks test this case: + // multipleDestructuredHookNames +}) + +describe(`request`, () => { + test(`can add header to request`, async ({ fetch }) => { + const headers = { + 'x-foo': 'bar', + } + const client2 = client.extend(async ({ send }) => { + // todo should be URL instance? + expectTypeOf(send).toEqualTypeOf() + expect(send.input).toEqual({ url: 'https://foo', document: `query { id \n }` }) + return await send({ + ...send.input, + headers, + }) + }) + fetch.mockImplementationOnce(async (input: Request) => { + expect(input.headers.get('x-foo')).toEqual(headers['x-foo']) + return createResponse({ data: { id: db.id } }) + }) + expect(await client2.query.id()).toEqual(db.id) + }) +}) diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index 2e7c97fd..7c343529 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -3,9 +3,6 @@ import { Errors } from '../../lib/errors/__.js' import type { SomeExecutionResultWithoutErrors } from '../../lib/graphql.js' import { type RootTypeName, rootTypeNameToOperationName } from '../../lib/graphql.js' import { isPlainObject } from '../../lib/prelude.js' -import type { SchemaInput } from '../0_functions/requestOrExecute.js' -import { requestOrExecute } from '../0_functions/requestOrExecute.js' -import type { Input as RequestOrExecuteInput } from '../0_functions/requestOrExecute.js' import { Schema } from '../1_Schema/__.js' import { readMaybeThunk } from '../1_Schema/core/helpers.js' import type { GlobalRegistry } from '../2_generator/globalRegistry.js' @@ -21,6 +18,11 @@ import type { } from './Config.js' import type { DocumentFn } from './document.js' import { toDocumentString } from './document.js' +import type { ErrorGraffleExtensionEntryHook } from './extension/getEntryHook.js' +import type { Extension } from './extension/types.js' +import type { SchemaInput } from './requestOrExecute.js' +import { requestOrExecute } from './requestOrExecute.js' +import type { Input as RequestOrExecuteInput } from './requestOrExecute.js' import type { GetRootTypeMethods } from './RootTypeMethods.js' type RawInput = Omit @@ -39,6 +41,9 @@ export type Client<$Index extends Schema.Index | null, $Config extends Config> = ? ClientTyped<$Index, $Config> : {} // eslint-disable-line ) + & { + extend: (extension: Extension) => Client<$Index, $Config> + } export type ClientTyped<$Index extends Schema.Index, $Config extends Config> = & { @@ -126,6 +131,15 @@ type Create = < export const create: Create = ( input_, +) => createInternal(input_, { extensions: [] }) + +interface State { + extensions: Extension[] +} + +export const createInternal = ( + input_: Input, + state: State, ) => { // eslint-disable-next-line // @ts-ignore passes after generation @@ -140,7 +154,7 @@ export const create: Create = ( const executeRootType = (context: Context, rootTypeName: RootTypeName) => - async (selection: GraphQLObjectSelection): Promise => { + async (selection: GraphQLObjectSelection): Promise => { const rootIndex = context.schemaIndex.Root[rootTypeName] if (!rootIndex) throw new Error(`Root type not found: ${rootTypeName}`) @@ -152,7 +166,12 @@ export const create: Create = ( selection[rootTypeNameToOperationName[rootTypeName]], ) // todo variables - const result = await requestOrExecute({ schema: input.schema, document: documentString }) + const result = await requestOrExecute({ + schema: input.schema, + document: documentString, + extensions: state.extensions, + }) + if (result instanceof Error) return result // todo optimize // 1. Generate a map of possible custom scalar paths (tree structure) // 2. When traversing the result, skip keys that are not in the map @@ -252,6 +271,7 @@ export const create: Create = ( ...input2, schema: input.schema, }) + if (result instanceof Error) throw result // todo consolidate if (result.errors && result.errors.length > 0) { throw new Errors.ContextualAggregateError( @@ -262,6 +282,9 @@ export const create: Create = ( } return result }, + extend: (extension: Extension) => { + return createInternal(input, { extensions: [...state.extensions, extension] }) + }, } if (input.schemaIndex) { @@ -297,6 +320,7 @@ export const create: Create = ( schema: input.schema, document: documentString, operationName, + extensions: state.extensions, // todo variables }) return handleReturn(schemaIndex, result, returnMode) @@ -316,6 +340,7 @@ export const create: Create = ( schema: input.schema, document: documentString, operationName, + extensions: state.extensions, // todo variables }) // todo refactor... @@ -334,21 +359,23 @@ export const create: Create = ( return client } +type GraffleExecutionResult = ExecutionResult | ErrorGraffleExtensionEntryHook + const handleReturn = ( schemaIndex: Schema.Index, - result: ExecutionResult, + result: GraffleExecutionResult, returnMode: ReturnModeType, ) => { switch (returnMode) { case `dataAndErrors`: case `successData`: case `data`: { - if (result.errors && result.errors.length > 0) { - const error = new Errors.ContextualAggregateError( + if (result instanceof Error || (result.errors && result.errors.length > 0)) { + const error = result instanceof Error ? result : (new Errors.ContextualAggregateError( `One or more errors in the execution result.`, {}, - result.errors, - ) + result.errors!, + )) if (returnMode === `data` || returnMode === `successData`) throw error return error } diff --git a/src/layers/5_client/extension/getEntryHook.ts b/src/layers/5_client/extension/getEntryHook.ts new file mode 100644 index 00000000..5972db50 --- /dev/null +++ b/src/layers/5_client/extension/getEntryHook.ts @@ -0,0 +1,54 @@ +import { analyzeFunction } from '../../../lib/analyzeFunction.js' +import { ContextualError } from '../../../lib/errors/ContextualError.js' +import type { Extension, HookName } from './types.js' +import { hookNames } from './types.js' + +export class ErrorGraffleExtensionEntryHook extends ContextualError< + 'ErrorGraffleExtensionEntryHook', + { issue: ExtensionEntryHookIssue } +> { + // todo add to context: parameters value parsed and raw + constructor(context: { issue: ExtensionEntryHookIssue }) { + super(`Extension must destructure the input object and select an entry hook to use.`, context) + } +} + +export const ExtensionEntryHookIssue = { + multipleParameters: `multipleParameters`, + noParameters: `noParameters`, + notDestructured: `notDestructured`, + destructuredWithoutEntryHook: `destructuredWithoutEntryHook`, + multipleDestructuredHookNames: `multipleDestructuredHookNames`, +} as const + +export type ExtensionEntryHookIssue = typeof ExtensionEntryHookIssue[keyof typeof ExtensionEntryHookIssue] + +export const getEntryHook = (extension: Extension): ErrorGraffleExtensionEntryHook | HookName => { + const x = analyzeFunction(extension) + if (x.parameters.length > 1) { + return new ErrorGraffleExtensionEntryHook({ issue: ExtensionEntryHookIssue.multipleParameters }) + } + const p = x.parameters[0] + if (!p) { + return new ErrorGraffleExtensionEntryHook({ issue: ExtensionEntryHookIssue.noParameters }) + } else { + if (p.type === `name`) { + return new ErrorGraffleExtensionEntryHook({ issue: ExtensionEntryHookIssue.notDestructured }) + } else { + if (p.names.length === 0) { + return new ErrorGraffleExtensionEntryHook({ issue: ExtensionEntryHookIssue.destructuredWithoutEntryHook }) + } + const hooks = p.names.filter(_ => hookNames.includes(_ as any)) as HookName[] + + if (hooks.length > 1) { + return new ErrorGraffleExtensionEntryHook({ issue: ExtensionEntryHookIssue.multipleDestructuredHookNames }) + } + const hook = hooks[0] + if (!hook) { + return new ErrorGraffleExtensionEntryHook({ issue: ExtensionEntryHookIssue.destructuredWithoutEntryHook }) + } else { + return hook + } + } + } +} diff --git a/src/layers/5_client/extension/runStack.ts b/src/layers/5_client/extension/runStack.ts new file mode 100644 index 00000000..e3c86fbb --- /dev/null +++ b/src/layers/5_client/extension/runStack.ts @@ -0,0 +1,17 @@ +import { request } from '../../0_functions/request.js' +import type { Extension } from './types.js' + +export const runStack = async (extensions: Extension[], input) => { + const [extension, ...rest] = extensions + if (!extension) { + return request(input) + } + const sendHook = async (input) => { + return await runStack(rest, input) + } + sendHook.input = input + const extensionInput = { + send: sendHook, + } + return await extension(extensionInput) +} diff --git a/src/layers/5_client/extension/types.ts b/src/layers/5_client/extension/types.ts new file mode 100644 index 00000000..1b67705a --- /dev/null +++ b/src/layers/5_client/extension/types.ts @@ -0,0 +1,16 @@ +import type { SomeAsyncFunction } from '../../../lib/prelude.js' +import type { NetworkRequest, NetworkRequestInput } from '../../0_functions/request.js' + +export type Hook<$Fn extends SomeAsyncFunction, $Input extends object> = $Fn & { input: $Input } + +export type NetworkRequestHook = Hook + +export type Extension = (hooks: { send: NetworkRequestHook }) => Promise + +export const hookNamesEnum = { + send: `send`, +} as const + +export const hookNames = Object.values(hookNamesEnum) + +export type HookName = keyof typeof hookNamesEnum diff --git a/src/layers/5_client/requestOrExecute.ts b/src/layers/5_client/requestOrExecute.ts new file mode 100644 index 00000000..fe66edd6 --- /dev/null +++ b/src/layers/5_client/requestOrExecute.ts @@ -0,0 +1,41 @@ +import type { ExecutionResult, GraphQLSchema } from 'graphql' +import { execute } from '../0_functions/execute.js' +import type { URLInput } from '../0_functions/request.js' +import type { BaseInput } from '../0_functions/types.js' +import type { ErrorGraffleExtensionEntryHook } from './extension/getEntryHook.js' +import { getEntryHook } from './extension/getEntryHook.js' +import { runStack } from './extension/runStack.js' +import type { Extension, HookName } from './extension/types.js' + +export type SchemaInput = URLInput | GraphQLSchema + +export interface Input extends BaseInput { + schema: SchemaInput + extensions: Extension[] +} + +export const requestOrExecute = async ( + input: Input, +): Promise => { + const { schema, extensions: _, ...baseInput } = input + + if (schema instanceof URL || typeof schema === `string`) { + const extensionsByEntrypoint: Record = { + send: [], + } + + for (const c of input.extensions) { + const hookName = getEntryHook(c) + if (hookName instanceof Error) { + return hookName + } + extensionsByEntrypoint[hookName].push(c) + } + + const initialInputHookSend = { url: schema, ...baseInput } + const result = runStack(extensionsByEntrypoint.send, initialInputHookSend) + return result + } + + return await execute({ schema, ...baseInput }) +} diff --git a/src/lib/analyzeFunction.ts b/src/lib/analyzeFunction.ts new file mode 100644 index 00000000..f0e93603 --- /dev/null +++ b/src/lib/analyzeFunction.ts @@ -0,0 +1,62 @@ +import { casesExhausted } from './prelude.js' + +type Parameter = { type: 'name'; value: string } | { type: 'destructured'; names: string[] } + +export const analyzeFunction = (fn: (...args: [...any[]]) => unknown) => { + const groups = fn.toString().match(functionPattern)?.groups + if (groups?.[`body`] === undefined) throw new Error(`Could not extract body from function.`) + const body = groups[`body`] + const parameters: Parameter[] = [] + + if (groups[`parameters`]) { + const results = [...groups[`parameters`].matchAll(functionParametersPattern)] + const resultParameters = results.map(result => { + const type = result.groups?.[`destructured`] ? `destructured` : result.groups?.[`name`] ? `name` : null + + switch (type) { + case `destructured`: + const valueRaw = result.groups![`destructured`]! + const names = [...valueRaw.matchAll(destructuredPattern)].map(result => { + const name = result.groups![`name`] + if (name === undefined) throw new Error(`Could not extract name from destructured parameter.`) + return name + }) + return { + type, + names, + } as const + case `name`: + return { + type, + value: result.groups![`name`]!, + } as const + case null: + throw new Error(`Could not determine type of parameter.`) + default: + throw casesExhausted(type) // eslint-disable-line + } + }) + + parameters.push(...resultParameters) + } + + return { + body, + parameters, + } +} + +/** + * @see https://regex101.com/r/9kCK86/4 + */ +const functionPattern = /(?:[A-z])?\s*(?:\((?.*)\))\s*(?:=>)?\s*{(?.*)(?=})/s + +/** + * @see https://regex101.com/r/tE2dV5/2 + */ +const functionParametersPattern = /(?\{[^}]+\})|(?[A-z_][A-z_0-9]*)/gs + +/** + * https://regex101.com/r/WHwazx/1 + */ +const destructuredPattern = /(?[A-z_][A-z_0-9]*)(?::[^},]+)?/gs diff --git a/src/lib/prelude.ts b/src/lib/prelude.ts index 4e0b740e..c8e26450 100644 --- a/src/lib/prelude.ts +++ b/src/lib/prelude.ts @@ -238,3 +238,5 @@ export type StringKeyof = keyof T & string export type MaybePromise = T | Promise export const capitalizeFirstLetter = (string: string) => string.charAt(0).toUpperCase() + string.slice(1) + +export type SomeAsyncFunction = (...args: any[]) => Promise From faf48c15b1c31710e97c4e79dc8d55506abaee9f Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Fri, 17 May 2024 23:19:03 -0400 Subject: [PATCH 02/30] name --- .../5_client/{client.customize.test.ts => client.extend.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/layers/5_client/{client.customize.test.ts => client.extend.test.ts} (100%) diff --git a/src/layers/5_client/client.customize.test.ts b/src/layers/5_client/client.extend.test.ts similarity index 100% rename from src/layers/5_client/client.customize.test.ts rename to src/layers/5_client/client.extend.test.ts From ae5cfd9d8e8f10d6d8cefd586af003c29f41bd89 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Sat, 18 May 2024 08:55:33 -0400 Subject: [PATCH 03/30] rename send to request --- src/layers/5_client/client.extend.test.ts | 25 ++++++++++++----------- src/layers/5_client/extension/runStack.ts | 15 +++++++++----- src/layers/5_client/extension/types.ts | 4 ++-- src/layers/5_client/requestOrExecute.ts | 6 +++--- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src/layers/5_client/client.extend.test.ts b/src/layers/5_client/client.extend.test.ts index c3586fc6..50a87ca5 100644 --- a/src/layers/5_client/client.extend.test.ts +++ b/src/layers/5_client/client.extend.test.ts @@ -36,7 +36,7 @@ describe('invalid destructuring cases', () => { }) test('destructuredWithoutEntryHook', async () => { // @ts-expect-error - const result = await make(async ({ send2 }) => {}) + const result = await make(async ({ request2 }) => {}) expect(result).toMatchInlineSnapshot( `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, ) @@ -48,7 +48,7 @@ describe('invalid destructuring cases', () => { }) test('multipleParameters', async () => { // @ts-expect-error - const result = await make(async ({ send }, two) => {}) + const result = await make(async ({ request }, two) => {}) expect(result).toMatchInlineSnapshot( `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, ) @@ -73,19 +73,18 @@ describe('invalid destructuring cases', () => { // multipleDestructuredHookNames }) +// todo each extension added should copy, not mutate the client + describe(`request`, () => { test(`can add header to request`, async ({ fetch }) => { - const headers = { - 'x-foo': 'bar', - } - const client2 = client.extend(async ({ send }) => { + const headers = { 'x-foo': 'bar' } + const client2 = client.extend(async ({ request }) => { + // todo should be raw input types but rather resolved // todo should be URL instance? - expectTypeOf(send).toEqualTypeOf() - expect(send.input).toEqual({ url: 'https://foo', document: `query { id \n }` }) - return await send({ - ...send.input, - headers, - }) + expectTypeOf(request).toEqualTypeOf() + expect(request.input).toEqual({ url: 'https://foo', document: `query { id \n }` }) + return await request({ ...request.input, headers }) + // todo expose fetch hook }) fetch.mockImplementationOnce(async (input: Request) => { expect(input.headers.get('x-foo')).toEqual(headers['x-foo']) @@ -94,3 +93,5 @@ describe(`request`, () => { expect(await client2.query.id()).toEqual(db.id) }) }) + +// todo test a throw from an extension diff --git a/src/layers/5_client/extension/runStack.ts b/src/layers/5_client/extension/runStack.ts index e3c86fbb..f34c0d7f 100644 --- a/src/layers/5_client/extension/runStack.ts +++ b/src/layers/5_client/extension/runStack.ts @@ -1,17 +1,22 @@ import { request } from '../../0_functions/request.js' -import type { Extension } from './types.js' +import type { Extension, HookName } from './types.js' export const runStack = async (extensions: Extension[], input) => { const [extension, ...rest] = extensions + if (!extension) { return request(input) } - const sendHook = async (input) => { + + const requestHook = async (input) => { return await runStack(rest, input) } - sendHook.input = input + requestHook.input = input + const extensionInput = { - send: sendHook, - } + // entry hooks + request: requestHook, + } satisfies Record + return await extension(extensionInput) } diff --git a/src/layers/5_client/extension/types.ts b/src/layers/5_client/extension/types.ts index 1b67705a..7b4bcdec 100644 --- a/src/layers/5_client/extension/types.ts +++ b/src/layers/5_client/extension/types.ts @@ -5,10 +5,10 @@ export type Hook<$Fn extends SomeAsyncFunction, $Input extends object> = $Fn & { export type NetworkRequestHook = Hook -export type Extension = (hooks: { send: NetworkRequestHook }) => Promise +export type Extension = (hooks: { request: NetworkRequestHook }) => Promise export const hookNamesEnum = { - send: `send`, + request: `request`, } as const export const hookNames = Object.values(hookNamesEnum) diff --git a/src/layers/5_client/requestOrExecute.ts b/src/layers/5_client/requestOrExecute.ts index fe66edd6..02732634 100644 --- a/src/layers/5_client/requestOrExecute.ts +++ b/src/layers/5_client/requestOrExecute.ts @@ -21,7 +21,7 @@ export const requestOrExecute = async ( if (schema instanceof URL || typeof schema === `string`) { const extensionsByEntrypoint: Record = { - send: [], + request: [], } for (const c of input.extensions) { @@ -32,8 +32,8 @@ export const requestOrExecute = async ( extensionsByEntrypoint[hookName].push(c) } - const initialInputHookSend = { url: schema, ...baseInput } - const result = runStack(extensionsByEntrypoint.send, initialInputHookSend) + const initialInputHookRequest = { url: schema, ...baseInput } + const result = runStack(extensionsByEntrypoint.request, initialInputHookRequest) return result } From d9fb7b3d5d5cef4aa2e8065d0619884b7d47b511 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Sat, 18 May 2024 18:50:01 -0400 Subject: [PATCH 04/30] extension library --- extension sketch.md | 146 +++++++++++++ src/layers/5_client/client.extend.test.ts | 21 +- src/layers/5_client/client.ts | 2 +- .../{getEntryHook.ts => getEntrypoint.ts} | 2 +- src/layers/5_client/extension/runStack.ts | 4 +- src/layers/5_client/extension/types.ts | 27 ++- src/layers/5_client/requestOrExecute.ts | 72 +++++- src/lib/anyware/main.spec.ts | 62 ++++++ src/lib/anyware/main.ts | 205 ++++++++++++++++++ src/lib/prelude.ts | 20 ++ 10 files changed, 539 insertions(+), 22 deletions(-) create mode 100644 extension sketch.md rename src/layers/5_client/extension/{getEntryHook.ts => getEntrypoint.ts} (95%) create mode 100644 src/lib/anyware/main.spec.ts create mode 100644 src/lib/anyware/main.ts diff --git a/extension sketch.md b/extension sketch.md new file mode 100644 index 00000000..80772800 --- /dev/null +++ b/extension sketch.md @@ -0,0 +1,146 @@ +```ts +// ------- base library -------- + +const createDeferred = () => { + let result, reject + const promise = new Promise(($resolve,$reject) => { + resolve = resolve + reject = reject + }) + return { + promise, + resolve, + reject + } +} + +// ------- system -------- + +const hookNamesOrderedBySequence = ['request', 'fetch'] + +const hookIndexes = { + request: 0, + fetch: 1, +} + +const getFirstHook = () => hookNamesOrderedBySequence[0] + +const getNextHook = (hookName) => hookNamesOrderedBySequence[hookIndexes[hookName]+1] ?? null + +const core = { + implementationsByHook: { + request: (input) => {}, + fetch: (input) => {}, + } +} + + + +const runHook = ({ core, name, done, originalInput, currentHookStack, nextHookStack }) => { + const [pausedExtension, ...nextCurrentHookStack] = currentHookStack + + // Going down the stack + // -------------------- + + if (pausedExtension) { + // The extension is responsible for calling the next hook. + const hook = withOriginalInput(originalInput, (nextOriginalInput) => { + // Once called, the extension is paused again and we continue down the current hook stack. + + const pausedExtension = createDeferred() + const nextNextHookStack = [...nextHookStack, pausedExtension] // tempting to mutate here but simpler to think about as copy. + + runHook({ + core, + name, + done, + originalInput: nextOriginalInput, + currentHookStack: nextCurrentHookStack, + nextHookStack: nextNextHookStack, + }) + + return pausedExtension.promise + }) + + + // The extension is resumed. It is responsible for calling the next hook. + + const envelope = { [name]: hook } + pausedExtension.resolve(envelope) + + return + } + + // Reached bottom of the stack + // --------------------------- + + // Run core to get result + + const implementation = core.implementationsByHook[name] + const result = await implementation(originalInput) + + // Return to root with the next result and hook stack + + done({ result, nextHookStack }) + + return +} + + + +const run = ({ core, initialInput, initialHookStack }) => { + let currentInput = initialInput + let currentHookStack = initialHookStack + + for (hookName of hookNamesOrderedBySequence) { + const doneDeferred = createDeferred() + runHook({ + core, + name: hookName, + done: doneDeferred.resolve, + originalInput: currentInput, + currentHookStack, + nextHookStack: [], + }) + const { result, nextHookStack } = await doneDeferred.promise + currentInput = result + currentHookStack = nextHookStack + } + + return currentInput // last loop result +} + + + +const runExtensions = (extensions) => { + return await run({ + core, + {}, // first hook input + extensions.map(fn => { + const deferred = createDeferred() + deferred.promise.then(fn) + return deferred.resolve + }) + }) +} + + + +// ----- application ----- + + + +const extensionA = ({ request/*START*/ }) => { + const { fetch }/*B2*/ = await request(request.input)/*A1*/ + const { value }/*D2*/ = await fetch(fetch.input)/*C1*/ + return value/*E1*/ +} + +const extensionB = ({ request/*A2*/ }) => { + const { fetch }/*C2*/ = await request(request.input)/*B1*/ + const { value }/*E2*/ = await fetch(fetch.input)/*D1*/ + return value/*END*/ +} + +runExtensions([extensionA, extensionB]) +``` diff --git a/src/layers/5_client/client.extend.test.ts b/src/layers/5_client/client.extend.test.ts index 50a87ca5..e6ade144 100644 --- a/src/layers/5_client/client.extend.test.ts +++ b/src/layers/5_client/client.extend.test.ts @@ -1,12 +1,13 @@ /* eslint-disable */ -import { describe, expect, expectTypeOf } from 'vitest' +import { describe, describe, expect, expectTypeOf } from 'vitest' import { db } from '../../../tests/_/db.js' import { createResponse, test } from '../../../tests/_/helpers.js' import { Graffle } from '../../../tests/_/schema/generated/__.js' -import { ErrorGraffleExtensionEntryHook } from './extension/getEntryHook.js' +import { ErrorGraffleExtensionEntryHook } from './extension/getEntrypoint.js' import { Extension, NetworkRequestHook } from './extension/types.js' const client = Graffle.create({ schema: 'https://foo', returnMode: 'dataAndErrors' }) +const headers = { 'x-foo': 'bar' } describe('invalid destructuring cases', () => { const make = async (extension: Extension) => @@ -75,9 +76,8 @@ describe('invalid destructuring cases', () => { // todo each extension added should copy, not mutate the client -describe(`request`, () => { +describe(`entrypoint request`, () => { test(`can add header to request`, async ({ fetch }) => { - const headers = { 'x-foo': 'bar' } const client2 = client.extend(async ({ request }) => { // todo should be raw input types but rather resolved // todo should be URL instance? @@ -92,6 +92,19 @@ describe(`request`, () => { }) expect(await client2.query.id()).toEqual(db.id) }) + test.only('can chain into fetch', async () => { + const client2 = client.extend(async ({ request }) => { + console.log(1) + const { fetch } = await request({ ...request.input, headers }) + console.log(2) + console.log(fetch) + return await fetch(fetch.input) + }) + expect(await client2.query.id()).toEqual(db.id) + }) +}) + +describe.todo(`entrypoint fetch`, () => { }) // todo test a throw from an extension diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index 7c343529..44388580 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -18,7 +18,7 @@ import type { } from './Config.js' import type { DocumentFn } from './document.js' import { toDocumentString } from './document.js' -import type { ErrorGraffleExtensionEntryHook } from './extension/getEntryHook.js' +import type { ErrorGraffleExtensionEntryHook } from './extension/getEntrypoint.js' import type { Extension } from './extension/types.js' import type { SchemaInput } from './requestOrExecute.js' import { requestOrExecute } from './requestOrExecute.js' diff --git a/src/layers/5_client/extension/getEntryHook.ts b/src/layers/5_client/extension/getEntrypoint.ts similarity index 95% rename from src/layers/5_client/extension/getEntryHook.ts rename to src/layers/5_client/extension/getEntrypoint.ts index 5972db50..d0e779c5 100644 --- a/src/layers/5_client/extension/getEntryHook.ts +++ b/src/layers/5_client/extension/getEntrypoint.ts @@ -23,7 +23,7 @@ export const ExtensionEntryHookIssue = { export type ExtensionEntryHookIssue = typeof ExtensionEntryHookIssue[keyof typeof ExtensionEntryHookIssue] -export const getEntryHook = (extension: Extension): ErrorGraffleExtensionEntryHook | HookName => { +export const getEntrypoint = (extension: Extension): ErrorGraffleExtensionEntryHook | HookName => { const x = analyzeFunction(extension) if (x.parameters.length > 1) { return new ErrorGraffleExtensionEntryHook({ issue: ExtensionEntryHookIssue.multipleParameters }) diff --git a/src/layers/5_client/extension/runStack.ts b/src/layers/5_client/extension/runStack.ts index f34c0d7f..47d11a69 100644 --- a/src/layers/5_client/extension/runStack.ts +++ b/src/layers/5_client/extension/runStack.ts @@ -1,7 +1,7 @@ import { request } from '../../0_functions/request.js' import type { Extension, HookName } from './types.js' -export const runStack = async (extensions: Extension[], input) => { +export const runHook = async (extensions: Extension[], input) => { const [extension, ...rest] = extensions if (!extension) { @@ -9,7 +9,7 @@ export const runStack = async (extensions: Extension[], input) => { } const requestHook = async (input) => { - return await runStack(rest, input) + return await runHook(rest, input) } requestHook.input = input diff --git a/src/layers/5_client/extension/types.ts b/src/layers/5_client/extension/types.ts index 7b4bcdec..7f3f79c4 100644 --- a/src/layers/5_client/extension/types.ts +++ b/src/layers/5_client/extension/types.ts @@ -1,16 +1,35 @@ import type { SomeAsyncFunction } from '../../../lib/prelude.js' import type { NetworkRequest, NetworkRequestInput } from '../../0_functions/request.js' -export type Hook<$Fn extends SomeAsyncFunction, $Input extends object> = $Fn & { input: $Input } +const HookNamePropertySymbol = Symbol(`HookNameProperty`) +type HookNamePropertySymbol = typeof HookNamePropertySymbol + +export type Hook< + $Name extends HookName = HookName, + $Fn extends SomeAsyncFunction = SomeAsyncFunction, + $Input extends object = object, +> = $Fn & { + [HookNamePropertySymbol]: $Name + input: $Input +} + +export const getHookName = (hook: Hook): HookName => hook[HookNamePropertySymbol] + +export const isHook = (value: unknown): value is Hook => { + return value ? typeof value === `object` ? HookNamePropertySymbol in value : false : false +} export type NetworkRequestHook = Hook export type Extension = (hooks: { request: NetworkRequestHook }) => Promise -export const hookNamesEnum = { +export const hookNameEnum = { request: `request`, + fetch: `fetch`, } as const -export const hookNames = Object.values(hookNamesEnum) +export const hookNames = Object.values(hookNameEnum) + +export type HookName = keyof typeof hookNameEnum -export type HookName = keyof typeof hookNamesEnum +export const hooksOrderedBySequence: HookName[] = [`request`, `fetch`] diff --git a/src/layers/5_client/requestOrExecute.ts b/src/layers/5_client/requestOrExecute.ts index 02732634..71f43da7 100644 --- a/src/layers/5_client/requestOrExecute.ts +++ b/src/layers/5_client/requestOrExecute.ts @@ -2,10 +2,10 @@ import type { ExecutionResult, GraphQLSchema } from 'graphql' import { execute } from '../0_functions/execute.js' import type { URLInput } from '../0_functions/request.js' import type { BaseInput } from '../0_functions/types.js' -import type { ErrorGraffleExtensionEntryHook } from './extension/getEntryHook.js' -import { getEntryHook } from './extension/getEntryHook.js' -import { runStack } from './extension/runStack.js' -import type { Extension, HookName } from './extension/types.js' +import type { ErrorGraffleExtensionEntryHook } from './extension/getEntrypoint.js' +import { getEntrypoint } from './extension/getEntrypoint.js' +import { runHook } from './extension/runStack.js' +import { type Extension, getHookName, type HookName, hooksOrderedBySequence, isHook } from './extension/types.js' export type SchemaInput = URLInput | GraphQLSchema @@ -14,28 +14,80 @@ export interface Input extends BaseInput { extensions: Extension[] } +type ExtensionsByEntrypoint = Record + +type AttachmentRegistry = Record + export const requestOrExecute = async ( input: Input, ): Promise => { const { schema, extensions: _, ...baseInput } = input if (schema instanceof URL || typeof schema === `string`) { - const extensionsByEntrypoint: Record = { + const extensionsByEntrypoint: ExtensionsByEntrypoint = { request: [], + fetch: [], } for (const c of input.extensions) { - const hookName = getEntryHook(c) - if (hookName instanceof Error) { - return hookName + const entrypoint = getEntrypoint(c) + if (entrypoint instanceof Error) { + return entrypoint } - extensionsByEntrypoint[hookName].push(c) + extensionsByEntrypoint[entrypoint].push(c) } const initialInputHookRequest = { url: schema, ...baseInput } - const result = runStack(extensionsByEntrypoint.request, initialInputHookRequest) + const result = runHook(extensionsByEntrypoint.request, initialInputHookRequest) return result } return await execute({ schema, ...baseInput }) } + +/** + * TODO + * Allow extensions to short circuit. + * But starting simple at first. + */ + +extension(input) + +const runExtensions = async (extensionsByEntrypoint: ExtensionsByEntrypoint) => { + const attachmentsRegistry: AttachmentRegistry = { + request: [], + fetch: [], + // todo? Special hook that gets final result and must return that (type of) + // The bottom of the stack would always be a passthrough function + // end: [], + } + for (const hook of hooksOrderedBySequence) { + const extensions = extensionsByEntrypoint[hook] + for (const extension of extensions) { + /** + * An extension output could be: + * + * 1. Input for downstream hook + * 2. Input for end + */ + // + const output = await extension(input /* todo */) + if (isHook(output)) { + const hookName = getHookName(output) + attachmentsRegistry[hookName].push(output) + } + } + } +} + +const runAttachmentsRegistry = async (attachmentRegistry: AttachmentRegistry) => { + for (const hook of hooksOrderedBySequence) { + const attachments = attachmentRegistry[hook] + await runAttachments(attachments) + } +} +const runAttachments = async (attachments) => { + for (const attachment of attachments) { + const result = await attachment() + } +} diff --git a/src/lib/anyware/main.spec.ts b/src/lib/anyware/main.spec.ts new file mode 100644 index 00000000..c4e10969 --- /dev/null +++ b/src/lib/anyware/main.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'vitest' +import type { Core, ExtensionInput } from './main.js' +import { runExtensions } from './main.js' + +type Input = { value: string } + +const core: Core = { + hookNamesOrderedBySequence: [`a`, `b`], + implementationsByHook: { + a: async (input: Input) => { + return { value: input.value + `+a` } + }, + b: async (input: Input) => { + return { value: input.value + `+b` } + }, + }, +} + +const run = async (...extensions: ExtensionInput[]) => { + const result = await runExtensions({ + core, + initialInput, + extensions, + }) + return result +} + +// const extensions = [async function ex1({ a }) { +// const { b } = await a(a.input) +// const result = await b(b.input) +// return result +// }, async function ex2({ a }) { +// const { b } = await a(a.input) +// const result = await b(b.input) +// return result +// }] + +const initialInput = { value: `initial` } + +describe(`no extensions`, () => { + test(`passthrough to implementation`, async () => { + const result = await run() + expect(result).toEqual({ value: `initial+a+b` }) + }) +}) + +describe(`one extension`, () => { + test(`can return own result`, async () => { + const result = await run(async ({ a }) => { + const { b } = await a(a.input) + await b(b.input) + return 0 + }) + expect(result).toEqual(0) + }) + test(`can short-circuit at start, return input`, () => { + expect(run(({ a }) => a.input)).resolves.toEqual(initialInput) + }) + test(`can short-circuit at start, return own result`, () => { + expect(run(() => 0)).resolves.toEqual(0) + }) +}) diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts new file mode 100644 index 00000000..34dca067 --- /dev/null +++ b/src/lib/anyware/main.ts @@ -0,0 +1,205 @@ +import type { Deferred, SomeAsyncFunction } from '../prelude.js' +import { casesExhausted, createDeferred } from '../prelude.js' + +const debug = (...args: any[]) => { + if (process.env[`DEBUG`]) { + console.log(...args) + } +} + +const withOriginalInput = <$X, $F extends (...args: any[]) => any>(originalInput: $X, fn: $F): $F & { input: $X } => { + // @ts-expect-error + fn.input = originalInput + // @ts-expect-error + return fn +} + +type Extension = { + name: string + body: Deferred + // todo rename this "next"? + deferred: Deferred +} + +type HookDoneData = + | { type: 'completed'; result: unknown; nextHookStack: Extension[] } + | { type: 'shortCircuited'; result: unknown } + +type HookDoneResolver = (input: HookDoneData) => void + +const runHook = async <$HookName extends string>( + { core, name, done, originalInput, currentHookStack, nextHookStack }: { + core: Core<$HookName> + name: $HookName + done: HookDoneResolver + originalInput: unknown + currentHookStack: Extension[] + nextHookStack: Extension[] + }, +) => { + const [pausedExtension, ...nextCurrentHookStack] = currentHookStack + + // Going down the stack + // -------------------- + + if (pausedExtension) { + const hookUsedDeferred = createDeferred() + + debug(`${name}: extension ${pausedExtension.name}`) + // The extension is responsible for calling the next hook. + const hook = withOriginalInput(originalInput, (nextOriginalInput) => { + // Once called, the extension is paused again and we continue down the current hook stack. + hookUsedDeferred.resolve(true) + + debug(`${name}: ${pausedExtension.name}: pause`) + const nextPausedExtension: Extension = { + ...pausedExtension, + deferred: createDeferred(), + } + const nextNextHookStack = [...nextHookStack, nextPausedExtension] // tempting to mutate here but simpler to think about as copy. + + runHook({ + core, + name, + done, + originalInput: nextOriginalInput, + currentHookStack: nextCurrentHookStack, + nextHookStack: nextNextHookStack, + }) + + return nextPausedExtension.deferred.promise + }) + + // The extension is resumed. It is responsible for calling the next hook. + + debug(`${name}: ${pausedExtension.name}: resume`) + const envelope = { [name]: hook } + pausedExtension.deferred.resolve(envelope) + + // Support early return from extensions + const { branch, result } = await Promise.race([ + hookUsedDeferred.promise.then(result => { + return { branch: `hook`, result } as const + }), + pausedExtension.body.promise.then(result => { + return { branch: `body`, result } as const + }), + ]) + + debug(`${name}: ${pausedExtension.name}: branch`, branch) + if (branch === `body`) { + result + done({ type: `shortCircuited`, result }) + } + + return + } + + // Reached bottom of the stack + // --------------------------- + + // Run core to get result + + const implementation = core.implementationsByHook[name] + const result = await implementation(originalInput) + + // Return to root with the next result and hook stack + + done({ type: `completed`, result, nextHookStack }) + + return +} + +export type Core<$Hook extends string = string> = { + hookNamesOrderedBySequence: $Hook[] + implementationsByHook: Record<$Hook, (input: any) => Promise> +} + +const run = async ( + { core, initialInput, initialHookStack }: { core: Core; initialInput: any; initialHookStack: Extension[] }, +) => { + let currentInput = initialInput + let currentHookStack = initialHookStack + + for (const hookName of core.hookNamesOrderedBySequence) { + debug(`running hook`, hookName) + const doneDeferred = createDeferred() + runHook({ + core, + name: hookName, + done: doneDeferred.resolve, + originalInput: currentInput, + currentHookStack, + nextHookStack: [], + }) + + const signal = await doneDeferred.promise + + switch (signal.type) { + case `completed`: { + const { result, nextHookStack } = signal + currentInput = result + currentHookStack = nextHookStack + break + } + case `shortCircuited`: { + const { result } = signal + return result + } + default: + casesExhausted(signal) + } + } + + debug(`ending`) + let currentResult = currentInput + for (const hook of currentHookStack) { + debug(`end: ${hook.name}`) + hook.deferred.resolve(currentResult) + currentResult = await hook.body.promise + } + + return currentResult // last loop result +} + +export type ExtensionInput = SomeAsyncFunction + +const prepare = (fn: SomeAsyncFunction) => { + const deferred = createDeferred() + const body = createDeferred() + deferred.promise.then(async (input) => { + const result = await fn(input) + body.resolve(result) + }) + return { + name: fn.name, + body, + deferred, + } +} + +export const runExtensions = async ( + { core, initialInput, extensions }: { core: Core; initialInput: any; extensions: ExtensionInput[] }, +) => { + return await run({ + core, + initialInput, + initialHookStack: extensions.map(prepare), + }) +} + +// // ----- application ----- + +// const extensionA = ({ request/*START*/ }) => { +// const { fetch }/*B2*/ = await request(request.input)/*A1*/ +// const { value }/*D2*/ = await fetch(fetch.input)/*C1*/ +// return value/*E1*/ +// } + +// const extensionB = ({ request/*A2*/ }) => { +// const { fetch }/*C2*/ = await request(request.input)/*B1*/ +// const { value }/*E2*/ = await fetch(fetch.input)/*D1*/ +// return value/*END*/ +// } + +// runExtensions([extensionA, extensionB]) diff --git a/src/lib/prelude.ts b/src/lib/prelude.ts index c8e26450..b8b172e6 100644 --- a/src/lib/prelude.ts +++ b/src/lib/prelude.ts @@ -1,3 +1,4 @@ +import { resolve } from 'dns' import type { ConditionalSimplifyDeep } from 'type-fest/source/conditional-simplify.js' /* eslint-disable */ @@ -240,3 +241,22 @@ export type MaybePromise = T | Promise export const capitalizeFirstLetter = (string: string) => string.charAt(0).toUpperCase() + string.slice(1) export type SomeAsyncFunction = (...args: any[]) => Promise + +export type Deferred = { + promise: Promise + resolve: (value: T) => void + reject: (error: Error) => void +} +export const createDeferred = <$T>(): Deferred<$T> => { + let resolve + let reject + const promise = new Promise(($resolve, $reject) => { + resolve = $resolve + reject = $reject + }) + return { + promise, + resolve, + reject, + } +} From fe4dad1ab2628fdf0fde2cca5e6cd15bc7b31330 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Sat, 18 May 2024 20:07:22 -0400 Subject: [PATCH 05/30] maybe async --- src/lib/anyware/main.spec.ts | 21 +++++++++++---------- src/lib/anyware/main.ts | 35 ++++++++--------------------------- src/lib/prelude.ts | 8 ++++++++ 3 files changed, 27 insertions(+), 37 deletions(-) diff --git a/src/lib/anyware/main.spec.ts b/src/lib/anyware/main.spec.ts index c4e10969..abf54ee0 100644 --- a/src/lib/anyware/main.spec.ts +++ b/src/lib/anyware/main.spec.ts @@ -46,17 +46,18 @@ describe(`no extensions`, () => { describe(`one extension`, () => { test(`can return own result`, async () => { - const result = await run(async ({ a }) => { - const { b } = await a(a.input) - await b(b.input) - return 0 - }) - expect(result).toEqual(0) + expect( + await run(async ({ a }) => { + const { b } = await a(a.input) + await b(b.input) + return 0 + }), + ).toEqual(0) }) - test(`can short-circuit at start, return input`, () => { - expect(run(({ a }) => a.input)).resolves.toEqual(initialInput) + test(`can short-circuit at start, return input`, async () => { + expect(await run(({ a }) => a.input)).toEqual(initialInput) }) - test(`can short-circuit at start, return own result`, () => { - expect(run(() => 0)).resolves.toEqual(0) + test(`can short-circuit at start, return own result`, async () => { + expect(await run(() => 0)).toEqual(0) }) }) diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts index 34dca067..2757de4d 100644 --- a/src/lib/anyware/main.ts +++ b/src/lib/anyware/main.ts @@ -1,11 +1,5 @@ -import type { Deferred, SomeAsyncFunction } from '../prelude.js' -import { casesExhausted, createDeferred } from '../prelude.js' - -const debug = (...args: any[]) => { - if (process.env[`DEBUG`]) { - console.log(...args) - } -} +import type { Deferred, SomeAsyncFunction, SomeMaybeAsyncFunction } from '../prelude.js' +import { casesExhausted, createDeferred, debug } from '../prelude.js' const withOriginalInput = <$X, $F extends (...args: any[]) => any>(originalInput: $X, fn: $F): $F & { input: $X } => { // @ts-expect-error @@ -17,7 +11,7 @@ const withOriginalInput = <$X, $F extends (...args: any[]) => any>(originalInput type Extension = { name: string body: Deferred - // todo rename this "next"? + // todo rename this "chunk"? deferred: Deferred } @@ -116,7 +110,7 @@ export type Core<$Hook extends string = string> = { } const run = async ( - { core, initialInput, initialHookStack }: { core: Core; initialInput: any; initialHookStack: Extension[] }, + { core, initialInput, initialHookStack }: { core: Core; initialInput: unknown; initialHookStack: Extension[] }, ) => { let currentInput = initialInput let currentHookStack = initialHookStack @@ -152,6 +146,7 @@ const run = async ( } debug(`ending`) + let currentResult = currentInput for (const hook of currentHookStack) { debug(`end: ${hook.name}`) @@ -159,10 +154,12 @@ const run = async ( currentResult = await hook.body.promise } + debug(`returning`) + return currentResult // last loop result } -export type ExtensionInput = SomeAsyncFunction +export type ExtensionInput = SomeMaybeAsyncFunction const prepare = (fn: SomeAsyncFunction) => { const deferred = createDeferred() @@ -187,19 +184,3 @@ export const runExtensions = async ( initialHookStack: extensions.map(prepare), }) } - -// // ----- application ----- - -// const extensionA = ({ request/*START*/ }) => { -// const { fetch }/*B2*/ = await request(request.input)/*A1*/ -// const { value }/*D2*/ = await fetch(fetch.input)/*C1*/ -// return value/*E1*/ -// } - -// const extensionB = ({ request/*A2*/ }) => { -// const { fetch }/*C2*/ = await request(request.input)/*B1*/ -// const { value }/*E2*/ = await fetch(fetch.input)/*D1*/ -// return value/*END*/ -// } - -// runExtensions([extensionA, extensionB]) diff --git a/src/lib/prelude.ts b/src/lib/prelude.ts index b8b172e6..57f419ba 100644 --- a/src/lib/prelude.ts +++ b/src/lib/prelude.ts @@ -242,6 +242,8 @@ export const capitalizeFirstLetter = (string: string) => string.charAt(0).toUppe export type SomeAsyncFunction = (...args: any[]) => Promise +export type SomeMaybeAsyncFunction = (...args: any[]) => MaybePromise + export type Deferred = { promise: Promise resolve: (value: T) => void @@ -260,3 +262,9 @@ export const createDeferred = <$T>(): Deferred<$T> => { reject, } } + +export const debug = (...args: any[]) => { + if (process.env[`DEBUG`]) { + console.log(...args) + } +} From af8dd0bea45d1062325a788b6035dfbffc614f98 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Sat, 18 May 2024 20:52:57 -0400 Subject: [PATCH 06/30] better testing --- src/lib/anyware/main.spec.ts | 40 ++++++++++++++++++++++++++---------- src/lib/anyware/main.ts | 7 +++++-- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/lib/anyware/main.spec.ts b/src/lib/anyware/main.spec.ts index abf54ee0..db4d11c3 100644 --- a/src/lib/anyware/main.spec.ts +++ b/src/lib/anyware/main.spec.ts @@ -1,22 +1,30 @@ -import { describe, expect, test } from 'vitest' +import type { Mock } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import type { Core, ExtensionInput } from './main.js' import { runExtensions } from './main.js' type Input = { value: string } +type $Core = Core<'a' | 'b', { a: Mock; b: Mock }> -const core: Core = { - hookNamesOrderedBySequence: [`a`, `b`], - implementationsByHook: { - a: async (input: Input) => { - return { value: input.value + `+a` } - }, - b: async (input: Input) => { - return { value: input.value + `+b` } - }, - }, +const createCore = (): $Core => { + const a = vi.fn().mockImplementation((input: Input) => { + return { value: input.value + `+a` } + }) + const b = vi.fn().mockImplementation((input: Input) => { + return { value: input.value + `+b` } + }) + + return { + hookNamesOrderedBySequence: [`a`, `b`], + implementationsByHook: { a, b }, + } } +let core: $Core + const run = async (...extensions: ExtensionInput[]) => { + core = createCore() + const result = await runExtensions({ core, initialInput, @@ -60,4 +68,14 @@ describe(`one extension`, () => { test(`can short-circuit at start, return own result`, async () => { expect(await run(() => 0)).toEqual(0) }) + test(`can short-circuit after first hook, return own result`, async () => { + expect( + await run(async ({ a }) => { + const { b } = await a(a.input) + return b.input.value + `+x` + }), + ).toEqual(`initial+a+x`) + expect(core.implementationsByHook.a).toHaveBeenCalled() + expect(core.implementationsByHook.b).not.toHaveBeenCalled() + }) }) diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts index 2757de4d..64f83189 100644 --- a/src/lib/anyware/main.ts +++ b/src/lib/anyware/main.ts @@ -104,9 +104,12 @@ const runHook = async <$HookName extends string>( return } -export type Core<$Hook extends string = string> = { +export type Core< + $Hook extends string = string, + $ImplementationsByHook extends Record<$Hook, SomeAsyncFunction> = Record<$Hook, SomeAsyncFunction>, +> = { hookNamesOrderedBySequence: $Hook[] - implementationsByHook: Record<$Hook, (input: any) => Promise> + implementationsByHook: $ImplementationsByHook } const run = async ( From 9e119929185d579957846ad89d5f1aa316bc54e4 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Sun, 19 May 2024 13:58:50 -0400 Subject: [PATCH 07/30] better function analysis and multi testing --- src/layers/5_client/client.extend.test.ts | 4 +- src/layers/5_client/client.ts | 4 +- src/layers/5_client/requestOrExecute.ts | 6 +- src/lib/analyzeFunction.ts | 15 +- .../anyware}/getEntrypoint.ts | 29 ++-- src/lib/anyware/main.spec.ts | 105 ++++++++---- src/lib/anyware/main.ts | 149 ++++++++++++++---- src/lib/anyware/snippet.ts | 24 +++ 8 files changed, 256 insertions(+), 80 deletions(-) rename src/{layers/5_client/extension => lib/anyware}/getEntrypoint.ts (50%) create mode 100644 src/lib/anyware/snippet.ts diff --git a/src/layers/5_client/client.extend.test.ts b/src/layers/5_client/client.extend.test.ts index e6ade144..166d6a9c 100644 --- a/src/layers/5_client/client.extend.test.ts +++ b/src/layers/5_client/client.extend.test.ts @@ -3,7 +3,7 @@ import { describe, describe, expect, expectTypeOf } from 'vitest' import { db } from '../../../tests/_/db.js' import { createResponse, test } from '../../../tests/_/helpers.js' import { Graffle } from '../../../tests/_/schema/generated/__.js' -import { ErrorGraffleExtensionEntryHook } from './extension/getEntrypoint.js' +import { ErrorAnywareExtensionEntrypoint } from '../../lib/anyware/getEntrypoint.js' import { Extension, NetworkRequestHook } from './extension/types.js' const client = Graffle.create({ schema: 'https://foo', returnMode: 'dataAndErrors' }) @@ -11,7 +11,7 @@ const headers = { 'x-foo': 'bar' } describe('invalid destructuring cases', () => { const make = async (extension: Extension) => - (await client.extend(extension).query.id()) as any as ErrorGraffleExtensionEntryHook + (await client.extend(extension).query.id()) as any as ErrorAnywareExtensionEntrypoint test('noParameters', async () => { const result = await make(async ({}) => {}) diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index 44388580..063281b1 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -1,4 +1,5 @@ import type { ExecutionResult } from 'graphql' +import type { ErrorAnywareExtensionEntrypoint } from '../../lib/anyware/getEntrypoint.js' import { Errors } from '../../lib/errors/__.js' import type { SomeExecutionResultWithoutErrors } from '../../lib/graphql.js' import { type RootTypeName, rootTypeNameToOperationName } from '../../lib/graphql.js' @@ -18,7 +19,6 @@ import type { } from './Config.js' import type { DocumentFn } from './document.js' import { toDocumentString } from './document.js' -import type { ErrorGraffleExtensionEntryHook } from './extension/getEntrypoint.js' import type { Extension } from './extension/types.js' import type { SchemaInput } from './requestOrExecute.js' import { requestOrExecute } from './requestOrExecute.js' @@ -359,7 +359,7 @@ export const createInternal = ( return client } -type GraffleExecutionResult = ExecutionResult | ErrorGraffleExtensionEntryHook +type GraffleExecutionResult = ExecutionResult | ErrorAnywareExtensionEntrypoint const handleReturn = ( schemaIndex: Schema.Index, diff --git a/src/layers/5_client/requestOrExecute.ts b/src/layers/5_client/requestOrExecute.ts index 71f43da7..6dbc344c 100644 --- a/src/layers/5_client/requestOrExecute.ts +++ b/src/layers/5_client/requestOrExecute.ts @@ -1,9 +1,9 @@ import type { ExecutionResult, GraphQLSchema } from 'graphql' +import type { ErrorAnywareExtensionEntrypoint } from '../../lib/anyware/getEntrypoint.js' +import { getEntrypoint } from '../../lib/anyware/getEntrypoint.js' import { execute } from '../0_functions/execute.js' import type { URLInput } from '../0_functions/request.js' import type { BaseInput } from '../0_functions/types.js' -import type { ErrorGraffleExtensionEntryHook } from './extension/getEntrypoint.js' -import { getEntrypoint } from './extension/getEntrypoint.js' import { runHook } from './extension/runStack.js' import { type Extension, getHookName, type HookName, hooksOrderedBySequence, isHook } from './extension/types.js' @@ -20,7 +20,7 @@ type AttachmentRegistry = Record export const requestOrExecute = async ( input: Input, -): Promise => { +): Promise => { const { schema, extensions: _, ...baseInput } = input if (schema instanceof URL || typeof schema === `string`) { diff --git a/src/lib/analyzeFunction.ts b/src/lib/analyzeFunction.ts index f0e93603..eca7e7be 100644 --- a/src/lib/analyzeFunction.ts +++ b/src/lib/analyzeFunction.ts @@ -4,8 +4,11 @@ type Parameter = { type: 'name'; value: string } | { type: 'destructured'; names export const analyzeFunction = (fn: (...args: [...any[]]) => unknown) => { const groups = fn.toString().match(functionPattern)?.groups - if (groups?.[`body`] === undefined) throw new Error(`Could not extract body from function.`) - const body = groups[`body`] + if (!groups) throw new Error(`Could not extract groups from function.`) + + const body = groups[`bodyStatement`] ?? groups[`bodyExpression`] + if (body === undefined) throw new Error(`Could not extract body from function.`) + const parameters: Parameter[] = [] if (groups[`parameters`]) { @@ -49,7 +52,13 @@ export const analyzeFunction = (fn: (...args: [...any[]]) => unknown) => { /** * @see https://regex101.com/r/9kCK86/4 */ -const functionPattern = /(?:[A-z])?\s*(?:\((?.*)\))\s*(?:=>)?\s*{(?.*)(?=})/s +// const functionPattern = /(?:[A-z])?\s*(?:\((?.*)\))\s*(?:=>)?\s*{(?.*)(?=})/s + +/** + * @see https://regex101.com/r/U0JtfS/1 + */ +const functionPattern = + /^(?:(?async)\s+)?(?:function\s+)?(?:(?[A-z_0-9]+)\s*)?\((?[^)]*)\)\s*(?:=>\s*(?[^\s{].*)|(?:=>\s*)?{(?.*)})$/s /** * @see https://regex101.com/r/tE2dV5/2 diff --git a/src/layers/5_client/extension/getEntrypoint.ts b/src/lib/anyware/getEntrypoint.ts similarity index 50% rename from src/layers/5_client/extension/getEntrypoint.ts rename to src/lib/anyware/getEntrypoint.ts index d0e779c5..81d6faff 100644 --- a/src/layers/5_client/extension/getEntrypoint.ts +++ b/src/lib/anyware/getEntrypoint.ts @@ -1,9 +1,9 @@ -import { analyzeFunction } from '../../../lib/analyzeFunction.js' -import { ContextualError } from '../../../lib/errors/ContextualError.js' -import type { Extension, HookName } from './types.js' -import { hookNames } from './types.js' +// import type { Extension, HookName } from '../../layers/5_client/extension/types.js' +import { analyzeFunction } from '../analyzeFunction.js' +import { ContextualError } from '../errors/ContextualError.js' +import type { ExtensionInput, HookName } from './main.js' -export class ErrorGraffleExtensionEntryHook extends ContextualError< +export class ErrorAnywareExtensionEntrypoint extends ContextualError< 'ErrorGraffleExtensionEntryHook', { issue: ExtensionEntryHookIssue } > { @@ -23,29 +23,32 @@ export const ExtensionEntryHookIssue = { export type ExtensionEntryHookIssue = typeof ExtensionEntryHookIssue[keyof typeof ExtensionEntryHookIssue] -export const getEntrypoint = (extension: Extension): ErrorGraffleExtensionEntryHook | HookName => { +export const getEntrypoint = ( + hookNames: string[], + extension: ExtensionInput, +): ErrorAnywareExtensionEntrypoint | HookName => { const x = analyzeFunction(extension) if (x.parameters.length > 1) { - return new ErrorGraffleExtensionEntryHook({ issue: ExtensionEntryHookIssue.multipleParameters }) + return new ErrorAnywareExtensionEntrypoint({ issue: ExtensionEntryHookIssue.multipleParameters }) } const p = x.parameters[0] if (!p) { - return new ErrorGraffleExtensionEntryHook({ issue: ExtensionEntryHookIssue.noParameters }) + return new ErrorAnywareExtensionEntrypoint({ issue: ExtensionEntryHookIssue.noParameters }) } else { if (p.type === `name`) { - return new ErrorGraffleExtensionEntryHook({ issue: ExtensionEntryHookIssue.notDestructured }) + return new ErrorAnywareExtensionEntrypoint({ issue: ExtensionEntryHookIssue.notDestructured }) } else { if (p.names.length === 0) { - return new ErrorGraffleExtensionEntryHook({ issue: ExtensionEntryHookIssue.destructuredWithoutEntryHook }) + return new ErrorAnywareExtensionEntrypoint({ issue: ExtensionEntryHookIssue.destructuredWithoutEntryHook }) } - const hooks = p.names.filter(_ => hookNames.includes(_ as any)) as HookName[] + const hooks = p.names.filter(_ => hookNames.includes(_ as any)) if (hooks.length > 1) { - return new ErrorGraffleExtensionEntryHook({ issue: ExtensionEntryHookIssue.multipleDestructuredHookNames }) + return new ErrorAnywareExtensionEntrypoint({ issue: ExtensionEntryHookIssue.multipleDestructuredHookNames }) } const hook = hooks[0] if (!hook) { - return new ErrorGraffleExtensionEntryHook({ issue: ExtensionEntryHookIssue.destructuredWithoutEntryHook }) + return new ErrorAnywareExtensionEntrypoint({ issue: ExtensionEntryHookIssue.destructuredWithoutEntryHook }) } else { return hook } diff --git a/src/lib/anyware/main.spec.ts b/src/lib/anyware/main.spec.ts index db4d11c3..4b19ed76 100644 --- a/src/lib/anyware/main.spec.ts +++ b/src/lib/anyware/main.spec.ts @@ -1,9 +1,10 @@ import type { Mock } from 'vitest' import { describe, expect, test, vi } from 'vitest' -import type { Core, ExtensionInput } from './main.js' +import type { Core, ExtensionInput, Options } from './main.js' import { runExtensions } from './main.js' type Input = { value: string } + type $Core = Core<'a' | 'b', { a: Mock; b: Mock }> const createCore = (): $Core => { @@ -20,30 +21,21 @@ const createCore = (): $Core => { } } -let core: $Core - -const run = async (...extensions: ExtensionInput[]) => { +const runWithOptions = async (extensions: ExtensionInput[] = [], options: Options = {}) => { core = createCore() const result = await runExtensions({ core, - initialInput, + initialInput: { value: `initial` }, extensions, + options, }) return result } -// const extensions = [async function ex1({ a }) { -// const { b } = await a(a.input) -// const result = await b(b.input) -// return result -// }, async function ex2({ a }) { -// const { b } = await a(a.input) -// const result = await b(b.input) -// return result -// }] +const run = async (...extensions: ExtensionInput[]) => runWithOptions(extensions, {}) -const initialInput = { value: `initial` } +let core: $Core describe(`no extensions`, () => { test(`passthrough to implementation`, async () => { @@ -61,21 +53,80 @@ describe(`one extension`, () => { return 0 }), ).toEqual(0) + expect(core.implementationsByHook.a).toHaveBeenCalled() + expect(core.implementationsByHook.b).toHaveBeenCalled() }) - test(`can short-circuit at start, return input`, async () => { - expect(await run(({ a }) => a.input)).toEqual(initialInput) + describe(`can short-circuit`, () => { + test(`at start, return input`, async () => { + expect( + // todo arrow function expression parsing not working + await run(({ a }) => { + return a.input + }), + ).toEqual({ value: `initial` }) + expect(core.implementationsByHook.a).not.toHaveBeenCalled() + expect(core.implementationsByHook.b).not.toHaveBeenCalled() + }) + test(`at start, return own result`, async () => { + expect( + // todo arrow function expression parsing not working + await run(({ a }) => { + return 0 + }), + ).toEqual(0) + expect(core.implementationsByHook.a).not.toHaveBeenCalled() + expect(core.implementationsByHook.b).not.toHaveBeenCalled() + }) + test(`after first hook, return own result`, async () => { + expect( + await run(async ({ a }) => { + const { b } = await a(a.input) + return b.input.value + `+x` + }), + ).toEqual(`initial+a+x`) + expect(core.implementationsByHook.a).toHaveBeenCalled() + expect(core.implementationsByHook.b).not.toHaveBeenCalled() + }) }) - test(`can short-circuit at start, return own result`, async () => { - expect(await run(() => 0)).toEqual(0) + describe(`can partially apply`, () => { + test(`only first hook`, async () => { + expect( + await run(async ({ a }) => { + return await a({ value: a.input.value + `+ext` }) + }), + ).toEqual({ value: `initial+ext+a+b` }) + expect(core.implementationsByHook.a).toHaveBeenCalled() + expect(core.implementationsByHook.b).toHaveBeenCalled() + }) + test(`only second hook`, async () => { + expect( + await run(async ({ b }) => { + return await b({ value: b.input.value + `+ext` }) + }), + ).toEqual({ value: `initial+a+ext+b` }) + expect(core.implementationsByHook.a).toHaveBeenCalled() + expect(core.implementationsByHook.b).toHaveBeenCalled() + }) + test(`only second hook + end`, async () => { + expect( + await run(async ({ b }) => { + const result = await b({ value: b.input.value + `+ext` }) + return result.value + `+end` + }), + ).toEqual(`initial+a+ext+b+end`) + expect(core.implementationsByHook.a).toHaveBeenCalled() + expect(core.implementationsByHook.b).toHaveBeenCalled() + }) }) - test(`can short-circuit after first hook, return own result`, async () => { - expect( - await run(async ({ a }) => { - const { b } = await a(a.input) - return b.input.value + `+x` - }), - ).toEqual(`initial+a+x`) - expect(core.implementationsByHook.a).toHaveBeenCalled() +}) + +describe(`two extensions`, () => { + test(`first can short-circuit`, async () => { + const ex1 = () => 1 + const ex2 = vi.fn().mockImplementation(() => 2) + expect(await runWithOptions([ex1, ex2], { entrypointSelectionMode: `off` })).toEqual(1) + expect(ex2).not.toHaveBeenCalled() + expect(core.implementationsByHook.a).not.toHaveBeenCalled() expect(core.implementationsByHook.b).not.toHaveBeenCalled() }) }) diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts index 64f83189..1d419018 100644 --- a/src/lib/anyware/main.ts +++ b/src/lib/anyware/main.ts @@ -1,7 +1,21 @@ import type { Deferred, SomeAsyncFunction, SomeMaybeAsyncFunction } from '../prelude.js' import { casesExhausted, createDeferred, debug } from '../prelude.js' +import { getEntrypoint } from './getEntrypoint.js' -const withOriginalInput = <$X, $F extends (...args: any[]) => any>(originalInput: $X, fn: $F): $F & { input: $X } => { +export type Core< + $Hook extends string = string, + $ImplementationsByHook extends Record<$Hook, SomeAsyncFunction> = Record<$Hook, SomeAsyncFunction>, +> = { + hookNamesOrderedBySequence: $Hook[] + implementationsByHook: $ImplementationsByHook +} + +export type HookName = string + +const createHook = <$X, $F extends (...args: any[]) => any>( + originalInput: $X, + fn: $F, +): $F & { input: $X } => { // @ts-expect-error fn.input = originalInput // @ts-expect-error @@ -10,11 +24,13 @@ const withOriginalInput = <$X, $F extends (...args: any[]) => any>(originalInput type Extension = { name: string + entrypoint: string body: Deferred - // todo rename this "chunk"? - deferred: Deferred + currentChunk: Deferred } +export type ExtensionInput = SomeMaybeAsyncFunction + type HookDoneData = | { type: 'completed'; result: unknown; nextHookStack: Extension[] } | { type: 'shortCircuited'; result: unknown } @@ -41,14 +57,14 @@ const runHook = async <$HookName extends string>( debug(`${name}: extension ${pausedExtension.name}`) // The extension is responsible for calling the next hook. - const hook = withOriginalInput(originalInput, (nextOriginalInput) => { + const hook = createHook(originalInput, (nextOriginalInput) => { // Once called, the extension is paused again and we continue down the current hook stack. hookUsedDeferred.resolve(true) debug(`${name}: ${pausedExtension.name}: pause`) const nextPausedExtension: Extension = { ...pausedExtension, - deferred: createDeferred(), + currentChunk: createDeferred(), } const nextNextHookStack = [...nextHookStack, nextPausedExtension] // tempting to mutate here but simpler to think about as copy. @@ -61,16 +77,19 @@ const runHook = async <$HookName extends string>( nextHookStack: nextNextHookStack, }) - return nextPausedExtension.deferred.promise + return nextPausedExtension.currentChunk.promise }) // The extension is resumed. It is responsible for calling the next hook. debug(`${name}: ${pausedExtension.name}: resume`) const envelope = { [name]: hook } - pausedExtension.deferred.resolve(envelope) + pausedExtension.currentChunk.resolve(envelope) - // Support early return from extensions + // If the extension does not return, it wants to tap into more hooks. + // If the extension returns the hook envelope, it wants the rest of the pipeline + // to pass through it. + // If the extension returns a non-hook-envelope value, it wants to short-circuit the pipeline. const { branch, result } = await Promise.race([ hookUsedDeferred.promise.then(result => { return { branch: `hook`, result } as const @@ -82,8 +101,18 @@ const runHook = async <$HookName extends string>( debug(`${name}: ${pausedExtension.name}: branch`, branch) if (branch === `body`) { - result - done({ type: `shortCircuited`, result }) + if (result === envelope) { + runHook({ + core, + name, + done, + originalInput, + currentHookStack: nextCurrentHookStack, + nextHookStack, + }) + } else { + done({ type: `shortCircuited`, result }) + } } return @@ -104,14 +133,6 @@ const runHook = async <$HookName extends string>( return } -export type Core< - $Hook extends string = string, - $ImplementationsByHook extends Record<$Hook, SomeAsyncFunction> = Record<$Hook, SomeAsyncFunction>, -> = { - hookNamesOrderedBySequence: $Hook[] - implementationsByHook: $ImplementationsByHook -} - const run = async ( { core, initialInput, initialHookStack }: { core: Core; initialInput: unknown; initialHookStack: Extension[] }, ) => { @@ -153,7 +174,7 @@ const run = async ( let currentResult = currentInput for (const hook of currentHookStack) { debug(`end: ${hook.name}`) - hook.deferred.resolve(currentResult) + hook.currentChunk.resolve(currentResult) currentResult = await hook.body.promise } @@ -162,28 +183,96 @@ const run = async ( return currentResult // last loop result } -export type ExtensionInput = SomeMaybeAsyncFunction +const createPassthrough = (hookName: string) => async (hookEnvelope) => { + return await hookEnvelope[hookName](hookEnvelope[hookName].input) +} -const prepare = (fn: SomeAsyncFunction) => { - const deferred = createDeferred() +const toInternalExtension = (core: Core, config: Config, extension: SomeAsyncFunction) => { + const currentChunk = createDeferred() const body = createDeferred() - deferred.promise.then(async (input) => { - const result = await fn(input) + const appplyBody = async (input) => { + const result = await extension(input) body.resolve(result) - }) + } + + switch (config.entrypointSelectionMode) { + case `off`: { + currentChunk.promise.then(appplyBody) + return { + name: extension.name, + entrypoint: core.hookNamesOrderedBySequence[0]!, // todo non-empty-array datastructure + body, + currentChunk, + } + } + case `optional`: + case `required`: { + const entrypoint = getEntrypoint(core.hookNamesOrderedBySequence, extension) + if (entrypoint instanceof Error) { + if (config.entrypointSelectionMode === `required`) { + // todo return error and make part of types + throw entrypoint + } else { + currentChunk.promise.then(appplyBody) + return { + name: extension.name, + entrypoint: core.hookNamesOrderedBySequence[0]!, // todo non-empty-array datastructure + body, + currentChunk, + } + } + } + + const hooksBeforeEntrypoint: HookName[] = [] + for (const hookName of core.hookNamesOrderedBySequence) { + if (hookName === entrypoint) break + hooksBeforeEntrypoint.push(hookName) + } + + const passthroughs = hooksBeforeEntrypoint.map((hookName) => createPassthrough(hookName)) + let currentChunkPromiseChain = currentChunk.promise + for (const passthrough of passthroughs) { + currentChunkPromiseChain = currentChunkPromiseChain.then(passthrough) + } + currentChunkPromiseChain.then(appplyBody) + + return { + name: extension.name, + entrypoint, + body, + currentChunk, + } + } + default: + throw casesExhausted(config.entrypointSelectionMode) + } +} + +type Config = Required +const resolveOptions = (options?: Options): Config => { return { - name: fn.name, - body, - deferred, + entrypointSelectionMode: options?.entrypointSelectionMode ?? `required`, } } +export type Options = { + /** + * @defaultValue `true` + */ + entrypointSelectionMode?: 'optional' | 'required' | 'off' +} + export const runExtensions = async ( - { core, initialInput, extensions }: { core: Core; initialInput: any; extensions: ExtensionInput[] }, + { core, initialInput, extensions, options }: { + core: Core + initialInput: any + extensions: ExtensionInput[] + options?: Options + }, ) => { return await run({ core, initialInput, - initialHookStack: extensions.map(prepare), + initialHookStack: extensions.map(extension => toInternalExtension(core, resolveOptions(options), extension)), }) } diff --git a/src/lib/anyware/snippet.ts b/src/lib/anyware/snippet.ts new file mode 100644 index 00000000..713b7332 --- /dev/null +++ b/src/lib/anyware/snippet.ts @@ -0,0 +1,24 @@ +const somethingBetter = something.extend(({ foo }) => { + // do whatever you want before foo + const { bar } = await foo({ ...foo.input, customThing: true }) + // do whatever you want after foo/before bar + const result = await bar({ ...bar.input, moarCustom: true }) + // do whatever you want after foo + return result +}) + +// -- Don't need anything before bar? skip it! + +const somethingBetter = something.extend(({ bar }) => { + // do whatever you want before bar + const result = await bar({ ...bar.input, moarCustom: true }) + // do whatever you want after foo + return result +}) + +// -- Don't need anything after foo? skip it! + +const somethingBetter = something.extend(({ foo }) => { + // do whatever you want before foo + return await foo({ ...foo.input, customThing: true }) +}) From 70dc4788e1e36231e2efdbeeaa002dc70dbf6984 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Sun, 19 May 2024 14:17:55 -0400 Subject: [PATCH 08/30] more test cases --- src/lib/anyware/main.spec.ts | 77 +++++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 10 deletions(-) diff --git a/src/lib/anyware/main.spec.ts b/src/lib/anyware/main.spec.ts index 4b19ed76..4c9467e0 100644 --- a/src/lib/anyware/main.spec.ts +++ b/src/lib/anyware/main.spec.ts @@ -21,7 +21,7 @@ const createCore = (): $Core => { } } -const runWithOptions = async (extensions: ExtensionInput[] = [], options: Options = {}) => { +const runWithOptions = (options: Options = {}) => async (...extensions: ExtensionInput[]) => { core = createCore() const result = await runExtensions({ @@ -33,7 +33,7 @@ const runWithOptions = async (extensions: ExtensionInput[] = [], options: Option return result } -const run = async (...extensions: ExtensionInput[]) => runWithOptions(extensions, {}) +const run = async (...extensions: ExtensionInput[]) => runWithOptions({})(...extensions) let core: $Core @@ -84,7 +84,6 @@ describe(`one extension`, () => { return b.input.value + `+x` }), ).toEqual(`initial+a+x`) - expect(core.implementationsByHook.a).toHaveBeenCalled() expect(core.implementationsByHook.b).not.toHaveBeenCalled() }) }) @@ -95,8 +94,6 @@ describe(`one extension`, () => { return await a({ value: a.input.value + `+ext` }) }), ).toEqual({ value: `initial+ext+a+b` }) - expect(core.implementationsByHook.a).toHaveBeenCalled() - expect(core.implementationsByHook.b).toHaveBeenCalled() }) test(`only second hook`, async () => { expect( @@ -104,8 +101,6 @@ describe(`one extension`, () => { return await b({ value: b.input.value + `+ext` }) }), ).toEqual({ value: `initial+a+ext+b` }) - expect(core.implementationsByHook.a).toHaveBeenCalled() - expect(core.implementationsByHook.b).toHaveBeenCalled() }) test(`only second hook + end`, async () => { expect( @@ -114,19 +109,81 @@ describe(`one extension`, () => { return result.value + `+end` }), ).toEqual(`initial+a+ext+b+end`) - expect(core.implementationsByHook.a).toHaveBeenCalled() - expect(core.implementationsByHook.b).toHaveBeenCalled() }) }) }) describe(`two extensions`, () => { + const run = runWithOptions({ entrypointSelectionMode: `optional` }) test(`first can short-circuit`, async () => { const ex1 = () => 1 const ex2 = vi.fn().mockImplementation(() => 2) - expect(await runWithOptions([ex1, ex2], { entrypointSelectionMode: `off` })).toEqual(1) + expect(await run(ex1, ex2)).toEqual(1) expect(ex2).not.toHaveBeenCalled() expect(core.implementationsByHook.a).not.toHaveBeenCalled() expect(core.implementationsByHook.b).not.toHaveBeenCalled() }) + + test(`each can adjust first hook then passthrough`, async () => { + const ex1 = ({ a }) => a({ value: a.input.value + `+ex1` }) + const ex2 = ({ a }) => a({ value: a.input.value + `+ex2` }) + expect(await run(ex1, ex2)).toEqual({ value: `initial+ex1+ex2+a+b` }) + }) + + test(`each can adjust each hook`, async () => { + const ex1 = async ({ a }) => { + const { b } = await a({ value: a.input.value + `+ex1` }) + return await b({ value: b.input.value + `+ex1` }) + } + const ex2 = async ({ a }) => { + const { b } = await a({ value: a.input.value + `+ex2` }) + return await b({ value: b.input.value + `+ex2` }) + } + expect(await run(ex1, ex2)).toEqual({ value: `initial+ex1+ex2+a+ex1+ex2+b` }) + }) + + test(`second can skip hook a`, async () => { + const ex1 = async ({ a }) => { + const { b } = await a({ value: a.input.value + `+ex1` }) + return await b({ value: b.input.value + `+ex1` }) + } + const ex2 = async ({ b }) => { + return await b({ value: b.input.value + `+ex2` }) + } + expect(await run(ex1, ex2)).toEqual({ value: `initial+ex1+a+ex1+ex2+b` }) + }) + test(`second can short-circuit before hook a`, async () => { + let ex1AfterA = false + const ex1 = async ({ a }) => { + const { b } = await a({ value: a.input.value + `+ex1` }) + ex1AfterA = true + } + const ex2 = async ({ a }) => { + return 2 + } + expect(await run(ex1, ex2)).toEqual(2) + expect(ex1AfterA).toBe(false) + expect(core.implementationsByHook.a).not.toHaveBeenCalled() + expect(core.implementationsByHook.b).not.toHaveBeenCalled() + }) + test(`second can short-circuit after hook a`, async () => { + let ex1AfterB = false + const ex1 = async ({ a }) => { + const { b } = await a({ value: a.input.value + `+ex1` }) + await b({ value: b.input.value + `+ex1` }) + ex1AfterB = true + } + const ex2 = async ({ a }) => { + await a({ value: a.input.value + `+ex2` }) + return 2 + } + expect(await run(ex1, ex2)).toEqual(2) + expect(ex1AfterB).toBe(false) + expect(core.implementationsByHook.a).toHaveBeenCalledOnce() + expect(core.implementationsByHook.b).not.toHaveBeenCalled() + }) }) + +// todo some tests regarding error handling +// extension throws an error +// implementation throws an error From 26e4309927270fa70711a611b18a03061e2cb14f Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Sun, 19 May 2024 22:20:19 -0400 Subject: [PATCH 09/30] implement the core --- eslint.config.js | 3 + src/layers/0_functions/requestOrExecute.ts | 23 ++ src/layers/5_client/client.ts | 56 ++--- src/layers/5_client/extension/runStack.ts | 22 -- src/layers/5_client/extension/types.ts | 35 --- src/layers/5_client/requestOrExecute.ts | 93 -------- src/layers/5_core/__.ts | 1 + src/layers/5_core/http.ts | 234 +++++++++++++++++++++ src/layers/5_core/types.ts | 29 +++ src/lib/anyware/main.ts | 26 ++- src/lib/prelude.ts | 41 ++++ 11 files changed, 372 insertions(+), 191 deletions(-) create mode 100644 src/layers/0_functions/requestOrExecute.ts delete mode 100644 src/layers/5_client/extension/runStack.ts delete mode 100644 src/layers/5_client/extension/types.ts delete mode 100644 src/layers/5_client/requestOrExecute.ts create mode 100644 src/layers/5_core/__.ts create mode 100644 src/layers/5_core/http.ts create mode 100644 src/layers/5_core/types.ts diff --git a/eslint.config.js b/eslint.config.js index 8313e130..0c8c7a9d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -10,4 +10,7 @@ export default tsEslint.config({ tsconfigRootDir: import.meta.dirname, }, }, + rules: { + ['@typescript-eslint/only-throw-error']: 'off', + }, }) diff --git a/src/layers/0_functions/requestOrExecute.ts b/src/layers/0_functions/requestOrExecute.ts new file mode 100644 index 00000000..b4978ac7 --- /dev/null +++ b/src/layers/0_functions/requestOrExecute.ts @@ -0,0 +1,23 @@ +import type { ExecutionResult, GraphQLSchema } from 'graphql' +import { execute } from './execute.js' +import type { URLInput } from './request.js' +import { request } from './request.js' +import type { BaseInput } from './types.js' + +export type SchemaInput = URLInput | GraphQLSchema + +export interface Input extends BaseInput { + schema: SchemaInput +} + +export const requestOrExecute = async ( + input: Input, +): Promise => { + const { schema, ...baseInput } = input + + if (schema instanceof URL || typeof schema === `string`) { + return await request({ url: schema, ...baseInput }) + } + + return await execute({ schema, ...baseInput }) +} diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index 063281b1..e69eb2de 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -1,15 +1,18 @@ -import type { ExecutionResult } from 'graphql' +import { type ExecutionResult } from 'graphql' import type { ErrorAnywareExtensionEntrypoint } from '../../lib/anyware/getEntrypoint.js' +import { runExtensions } from '../../lib/anyware/main.js' import { Errors } from '../../lib/errors/__.js' import type { SomeExecutionResultWithoutErrors } from '../../lib/graphql.js' import { type RootTypeName, rootTypeNameToOperationName } from '../../lib/graphql.js' import { isPlainObject } from '../../lib/prelude.js' +import { requestOrExecute, SchemaInput } from '../0_functions/requestOrExecute.js' import { Schema } from '../1_Schema/__.js' import { readMaybeThunk } from '../1_Schema/core/helpers.js' import type { GlobalRegistry } from '../2_generator/globalRegistry.js' import { SelectionSet } from '../3_SelectionSet/__.js' import type { Context, DocumentObject, GraphQLObjectSelection } from '../3_SelectionSet/encode.js' import * as CustomScalars from '../4_ResultSet/customScalars.js' +import { Core } from '../5_core/__.js' import type { ApplyInputDefaults, Config, @@ -19,10 +22,6 @@ import type { } from './Config.js' import type { DocumentFn } from './document.js' import { toDocumentString } from './document.js' -import type { Extension } from './extension/types.js' -import type { SchemaInput } from './requestOrExecute.js' -import { requestOrExecute } from './requestOrExecute.js' -import type { Input as RequestOrExecuteInput } from './requestOrExecute.js' import type { GetRootTypeMethods } from './RootTypeMethods.js' type RawInput = Omit @@ -133,17 +132,18 @@ export const create: Create = ( input_, ) => createInternal(input_, { extensions: [] }) -interface State { - extensions: Extension[] +interface CreateState { + extensions: Extension[] // todo Graffle extension } export const createInternal = ( input_: Input, - state: State, + state: CreateState, ) => { // eslint-disable-next-line // @ts-ignore passes after generation const input = input_ as Readonly> + /** * @remarks Without generation the type of returnMode can be `ReturnModeTypeBase` which leads * TS to think some errors below are invalid checks because of a non-present member. @@ -152,33 +152,10 @@ export const createInternal = ( */ const returnMode = input.returnMode ?? `data` as ReturnModeType - const executeRootType = - (context: Context, rootTypeName: RootTypeName) => - async (selection: GraphQLObjectSelection): Promise => { - const rootIndex = context.schemaIndex.Root[rootTypeName] - if (!rootIndex) throw new Error(`Root type not found: ${rootTypeName}`) - - // todo turn inputs into variables - const documentString = SelectionSet.Print.rootTypeSelectionSet( - context, - rootIndex, - // @ts-expect-error fixme - selection[rootTypeNameToOperationName[rootTypeName]], - ) - // todo variables - const result = await requestOrExecute({ - schema: input.schema, - document: documentString, - extensions: state.extensions, - }) - if (result instanceof Error) return result - // todo optimize - // 1. Generate a map of possible custom scalar paths (tree structure) - // 2. When traversing the result, skip keys that are not in the map - // todo rename Result.decode - const dataDecoded = CustomScalars.decode(rootIndex, result.data) - return { ...result, data: dataDecoded } - } + // const executeRootType = + // (context: Context, rootTypeName: RootTypeName) => + // async (selection: GraphQLObjectSelection): Promise => { + // } const executeRootTypeField = (context: Context, rootTypeName: RootTypeName, key: string) => { return async (argsOrSelectionSet?: object) => { @@ -296,6 +273,15 @@ export const createInternal = ( }, } + const core = Core.createHttp(context) + + runExtensions({ + core: core, + extensions: [], + initialInput: {}, + options: {}, + }) + Object.assign(client, { document: (documentObject: DocumentObject) => { const run = async (operationName: string) => { diff --git a/src/layers/5_client/extension/runStack.ts b/src/layers/5_client/extension/runStack.ts deleted file mode 100644 index 47d11a69..00000000 --- a/src/layers/5_client/extension/runStack.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { request } from '../../0_functions/request.js' -import type { Extension, HookName } from './types.js' - -export const runHook = async (extensions: Extension[], input) => { - const [extension, ...rest] = extensions - - if (!extension) { - return request(input) - } - - const requestHook = async (input) => { - return await runHook(rest, input) - } - requestHook.input = input - - const extensionInput = { - // entry hooks - request: requestHook, - } satisfies Record - - return await extension(extensionInput) -} diff --git a/src/layers/5_client/extension/types.ts b/src/layers/5_client/extension/types.ts deleted file mode 100644 index 7f3f79c4..00000000 --- a/src/layers/5_client/extension/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { SomeAsyncFunction } from '../../../lib/prelude.js' -import type { NetworkRequest, NetworkRequestInput } from '../../0_functions/request.js' - -const HookNamePropertySymbol = Symbol(`HookNameProperty`) -type HookNamePropertySymbol = typeof HookNamePropertySymbol - -export type Hook< - $Name extends HookName = HookName, - $Fn extends SomeAsyncFunction = SomeAsyncFunction, - $Input extends object = object, -> = $Fn & { - [HookNamePropertySymbol]: $Name - input: $Input -} - -export const getHookName = (hook: Hook): HookName => hook[HookNamePropertySymbol] - -export const isHook = (value: unknown): value is Hook => { - return value ? typeof value === `object` ? HookNamePropertySymbol in value : false : false -} - -export type NetworkRequestHook = Hook - -export type Extension = (hooks: { request: NetworkRequestHook }) => Promise - -export const hookNameEnum = { - request: `request`, - fetch: `fetch`, -} as const - -export const hookNames = Object.values(hookNameEnum) - -export type HookName = keyof typeof hookNameEnum - -export const hooksOrderedBySequence: HookName[] = [`request`, `fetch`] diff --git a/src/layers/5_client/requestOrExecute.ts b/src/layers/5_client/requestOrExecute.ts deleted file mode 100644 index 6dbc344c..00000000 --- a/src/layers/5_client/requestOrExecute.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { ExecutionResult, GraphQLSchema } from 'graphql' -import type { ErrorAnywareExtensionEntrypoint } from '../../lib/anyware/getEntrypoint.js' -import { getEntrypoint } from '../../lib/anyware/getEntrypoint.js' -import { execute } from '../0_functions/execute.js' -import type { URLInput } from '../0_functions/request.js' -import type { BaseInput } from '../0_functions/types.js' -import { runHook } from './extension/runStack.js' -import { type Extension, getHookName, type HookName, hooksOrderedBySequence, isHook } from './extension/types.js' - -export type SchemaInput = URLInput | GraphQLSchema - -export interface Input extends BaseInput { - schema: SchemaInput - extensions: Extension[] -} - -type ExtensionsByEntrypoint = Record - -type AttachmentRegistry = Record - -export const requestOrExecute = async ( - input: Input, -): Promise => { - const { schema, extensions: _, ...baseInput } = input - - if (schema instanceof URL || typeof schema === `string`) { - const extensionsByEntrypoint: ExtensionsByEntrypoint = { - request: [], - fetch: [], - } - - for (const c of input.extensions) { - const entrypoint = getEntrypoint(c) - if (entrypoint instanceof Error) { - return entrypoint - } - extensionsByEntrypoint[entrypoint].push(c) - } - - const initialInputHookRequest = { url: schema, ...baseInput } - const result = runHook(extensionsByEntrypoint.request, initialInputHookRequest) - return result - } - - return await execute({ schema, ...baseInput }) -} - -/** - * TODO - * Allow extensions to short circuit. - * But starting simple at first. - */ - -extension(input) - -const runExtensions = async (extensionsByEntrypoint: ExtensionsByEntrypoint) => { - const attachmentsRegistry: AttachmentRegistry = { - request: [], - fetch: [], - // todo? Special hook that gets final result and must return that (type of) - // The bottom of the stack would always be a passthrough function - // end: [], - } - for (const hook of hooksOrderedBySequence) { - const extensions = extensionsByEntrypoint[hook] - for (const extension of extensions) { - /** - * An extension output could be: - * - * 1. Input for downstream hook - * 2. Input for end - */ - // - const output = await extension(input /* todo */) - if (isHook(output)) { - const hookName = getHookName(output) - attachmentsRegistry[hookName].push(output) - } - } - } -} - -const runAttachmentsRegistry = async (attachmentRegistry: AttachmentRegistry) => { - for (const hook of hooksOrderedBySequence) { - const attachments = attachmentRegistry[hook] - await runAttachments(attachments) - } -} -const runAttachments = async (attachments) => { - for (const attachment of attachments) { - const result = await attachment() - } -} diff --git a/src/layers/5_core/__.ts b/src/layers/5_core/__.ts new file mode 100644 index 00000000..fa55b580 --- /dev/null +++ b/src/layers/5_core/__.ts @@ -0,0 +1 @@ +export * as Core from './http.js' diff --git a/src/layers/5_core/http.ts b/src/layers/5_core/http.ts new file mode 100644 index 00000000..7a370490 --- /dev/null +++ b/src/layers/5_core/http.ts @@ -0,0 +1,234 @@ +import type { DocumentNode, ExecutionResult, GraphQLSchema } from 'graphql' +import { print } from 'graphql' +import type { Core } from '../../lib/anyware/main.js' +import type { StandardScalarVariables } from '../../lib/graphql.js' +import { parseExecutionResult } from '../../lib/graphqlHTTP.js' +import { CONTENT_TYPE_GQL } from '../../lib/http.js' +import { casesExhausted } from '../../lib/prelude.js' +import { execute } from '../0_functions/execute.js' +import type { Schema } from '../1_Schema/__.js' +import { SelectionSet } from '../3_SelectionSet/__.js' +import * as Result from '../4_ResultSet/customScalars.js' +import type { + ContextInterfaceRaw, + ContextInterfaceTyped, + InterfaceRaw, + InterfaceTyped, + TransportHttp, + TransportMemory, +} from './types.js' + +const getRootIndexOrThrow = (context: ContextInterfaceTyped, rootTypeName: string) => { + // @ts-expect-error + const rootIndex = context.schemaIndex.Root[rootTypeName] + if (!rootIndex) throw new Error(`Root type not found: ${rootTypeName}`) + return rootIndex +} + +type InterfaceInput = + | ({ + interface: InterfaceTyped + context: ContextInterfaceTyped + rootTypeName: Schema.RootTypeName + } & A) + | ({ + interface: InterfaceRaw + context: ContextInterfaceRaw + } & B) + +type TransportInput = + | ({ + transport: TransportHttp + } & A) + | ({ + transport: TransportMemory + } & B) + +export const createCore = (): Core<[`encode`, `pack`, `exchange`, `unpack`, `decode`], { + encode: + & InterfaceInput<{}, { document: string | DocumentNode }> + & TransportInput<{ schema: string | URL }, { schema: GraphQLSchema }> + pack: + & { + document: string | DocumentNode + variables: StandardScalarVariables + operationName?: string + } + & InterfaceInput + & TransportInput<{ url: string | URL; headers?: HeadersInit }, { schema: GraphQLSchema }> + exchange: + & InterfaceInput + & TransportInput< + { request: Request }, + { + schema: GraphQLSchema + document: string | DocumentNode + variables: StandardScalarVariables + operationName?: string + } + > + unpack: + & InterfaceInput + & TransportInput< + { response: Response }, + { + result: ExecutionResult + } + > + decode: + & { result: ExecutionResult } + & InterfaceInput + // todo this depends on the return mode +}, ExecutionResult> => { + return { + hookNamesOrderedBySequence: [`encode`, `pack`, `exchange`, `unpack`, `decode`], + implementationsByHook: { + encode: ( + input, + ) => { + let document: string | DocumentNode + switch (input.interface) { + case `raw`: { + document = input.document + break + } + case `typed`: { + // todo turn inputs into variables + document = SelectionSet.Print.rootTypeSelectionSet( + input.context, + getRootIndexOrThrow(input.context, input.rootTypeName), + // @ts-expect-error fixme + selection[rootTypeNameToOperationName[input.rootTypeName]], + ) + break + } + default: + throw casesExhausted(input) + } + + switch (input.transport) { + case `http`: { + return { + ...input, + transport: input.transport, + url: input.schema, + document, + variables: {}, + // operationName: '', + } + } + case `memory`: { + return { + ...input, + transport: input.transport, + schema: input.schema, + document, + variables: {}, + // operationName: '', + } + } + } + }, + pack: (input) => { + const documentPrinted = typeof input.document === `string` + ? input.document + : print(input.document) + + switch (input.transport) { + case `http`: { + const body = { + query: documentPrinted, + variables: input.variables, + operationName: input.operationName, + } + + const bodyEncoded = JSON.stringify(body) + + const requestConfig = new Request(input.url, { + method: `POST`, + headers: new Headers({ + 'accept': CONTENT_TYPE_GQL, + ...Object.fromEntries(new Headers(input.headers).entries()), + }), + body: bodyEncoded, + }) + + return { + ...input, + request: requestConfig, + } + } + case `memory`: { + return { + ...input, + } + } + default: + throw casesExhausted(input) + } + }, + exchange: async (input) => { + switch (input.transport) { + case `http`: { + const response = await fetch(input.request) + return { + ...input, + response, + } + } + case `memory`: { + const result = await execute({ + schema: input.schema, + document: input.document, + variables: input.variables, + operationName: input.operationName, + }) + return { + ...input, + result, + } + } + default: + throw casesExhausted(input) + } + }, + unpack: async (input) => { + switch (input.transport) { + case `http`: { + const json = await input.response.json() as object + const result = parseExecutionResult(json) + return { + ...input, + result, + } + } + case `memory`: { + return { + ...input, + result: input.result, + } + } + default: + throw casesExhausted(input) + } + }, + decode: (input) => { + switch (input.interface) { + // todo this depends on the return mode + case `raw`: { + return input.result + } + case `typed`: { + // todo optimize + // 1. Generate a map of possible custom scalar paths (tree structure) + // 2. When traversing the result, skip keys that are not in the map + const dataDecoded = Result.decode(getRootIndexOrThrow(input.context, input.rootTypeName), input.result.data) + return { ...input.result, data: dataDecoded } + } + default: + throw casesExhausted(input) + } + }, + }, + } +} diff --git a/src/layers/5_core/types.ts b/src/layers/5_core/types.ts new file mode 100644 index 00000000..f436b83d --- /dev/null +++ b/src/layers/5_core/types.ts @@ -0,0 +1,29 @@ +import type { Schema } from '../1_Schema/__.js' +import type { ReturnModeType } from '../5_client/Config.js' + +export type Transport = TransportMemory | TransportHttp +export type TransportMemory = 'memory' +export type TransportHttp = 'http' + +export type Interface = InterfaceRaw | InterfaceTyped + +export type InterfaceRaw = 'raw' + +export type InterfaceTyped = 'typed' + +type BaseContext = { + transport: Transport + config: { + returnMode: ReturnModeType + } +} + +export type Context = ContextInterfaceTyped | ContextInterfaceRaw + +export type ContextInterfaceTyped = + & BaseContext + & ({ interface: InterfaceTyped; schemaIndex: Schema.Index }) + +export type ContextInterfaceRaw = BaseContext & { + interface: InterfaceRaw +} diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts index 1d419018..baba5670 100644 --- a/src/lib/anyware/main.ts +++ b/src/lib/anyware/main.ts @@ -1,13 +1,27 @@ -import type { Deferred, SomeAsyncFunction, SomeMaybeAsyncFunction } from '../prelude.js' +import type { + Deferred, + FindValueAfter, + IsLastValue, + MaybePromise, + SomeAsyncFunction, + SomeMaybeAsyncFunction, +} from '../prelude.js' import { casesExhausted, createDeferred, debug } from '../prelude.js' import { getEntrypoint } from './getEntrypoint.js' export type Core< - $Hook extends string = string, - $ImplementationsByHook extends Record<$Hook, SomeAsyncFunction> = Record<$Hook, SomeAsyncFunction>, + $Hooks extends [string, ...string[]], + $HookMap extends Record<$Hooks[number], object> = Record<$Hooks[number], object>, + $Result = unknown, > = { - hookNamesOrderedBySequence: $Hook[] - implementationsByHook: $ImplementationsByHook + hookNamesOrderedBySequence: $Hooks + implementationsByHook: { + [$HookName in $Hooks[number]]: ( + input: $HookMap[$HookName], + ) => MaybePromise< + IsLastValue<$HookName, $Hooks> extends true ? $Result : $HookMap[FindValueAfter<$HookName, $Hooks>] + > + } } export type HookName = string @@ -39,7 +53,7 @@ type HookDoneResolver = (input: HookDoneData) => void const runHook = async <$HookName extends string>( { core, name, done, originalInput, currentHookStack, nextHookStack }: { - core: Core<$HookName> + core: Core name: $HookName done: HookDoneResolver originalInput: unknown diff --git a/src/lib/prelude.ts b/src/lib/prelude.ts index 57f419ba..47a5174c 100644 --- a/src/lib/prelude.ts +++ b/src/lib/prelude.ts @@ -268,3 +268,44 @@ export const debug = (...args: any[]) => { console.log(...args) } } + +export type PlusOneUpToTen = n extends 0 ? 1 + : n extends 1 ? 2 + : n extends 2 ? 3 + : n extends 3 ? 4 + : n extends 4 ? 5 + : n extends 5 ? 6 + : n extends 6 ? 7 + : n extends 7 ? 8 + : n extends 8 ? 9 + : n extends 9 ? 10 + : never + +export type MinusOneUpToTen = n extends 10 ? 9 + : n extends 9 ? 8 + : n extends 8 ? 7 + : n extends 7 ? 6 + : n extends 6 ? 5 + : n extends 5 ? 4 + : n extends 4 ? 3 + : n extends 3 ? 2 + : n extends 2 ? 1 + : n extends 1 ? 0 + : never + +export type findIndexForValue = findIndexForValue_ +type findIndexForValue_ = value extends list[i] ? i + : findIndexForValue_> + +export type FindValueAfter = list[PlusOneUpToTen>] + +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 From d0107e85a196b91a41ddefb1116f8ea48bf9a2e8 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 20 May 2024 10:07:13 -0400 Subject: [PATCH 10/30] typesafe extension --- src/layers/5_client/client.extend.test.ts | 90 +++------------------- src/layers/5_client/client.ts | 10 +-- src/layers/5_core/__.ts | 2 +- src/layers/5_core/{http.ts => core.ts} | 12 ++- src/lib/anyware/__.ts | 1 + src/lib/anyware/main.spec.ts | 94 +++++++++++++++++++---- src/lib/anyware/main.ts | 77 +++++++++++++++++-- src/lib/anyware/snippet.ts | 24 ------ src/lib/prelude.ts | 13 ++-- 9 files changed, 184 insertions(+), 139 deletions(-) rename src/layers/5_core/{http.ts => core.ts} (96%) create mode 100644 src/lib/anyware/__.ts delete mode 100644 src/lib/anyware/snippet.ts diff --git a/src/layers/5_client/client.extend.test.ts b/src/layers/5_client/client.extend.test.ts index 166d6a9c..ee0876c9 100644 --- a/src/layers/5_client/client.extend.test.ts +++ b/src/layers/5_client/client.extend.test.ts @@ -1,90 +1,23 @@ /* eslint-disable */ -import { describe, describe, expect, expectTypeOf } from 'vitest' +import { describe, expect } from 'vitest' import { db } from '../../../tests/_/db.js' import { createResponse, test } from '../../../tests/_/helpers.js' import { Graffle } from '../../../tests/_/schema/generated/__.js' -import { ErrorAnywareExtensionEntrypoint } from '../../lib/anyware/getEntrypoint.js' -import { Extension, NetworkRequestHook } from './extension/types.js' const client = Graffle.create({ schema: 'https://foo', returnMode: 'dataAndErrors' }) const headers = { 'x-foo': 'bar' } -describe('invalid destructuring cases', () => { - const make = async (extension: Extension) => - (await client.extend(extension).query.id()) as any as ErrorAnywareExtensionEntrypoint - - test('noParameters', async () => { - const result = await make(async ({}) => {}) - expect(result).toMatchInlineSnapshot( - `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, - ) - expect(result.context).toMatchInlineSnapshot(` - { - "issue": "noParameters", - } - `) - }) - test('noParameters', async () => { - const result = await make(async () => {}) - expect(result).toMatchInlineSnapshot( - `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, - ) - expect(result.context).toMatchInlineSnapshot(` - { - "issue": "noParameters", - } - `) - }) - test('destructuredWithoutEntryHook', async () => { - // @ts-expect-error - const result = await make(async ({ request2 }) => {}) - expect(result).toMatchInlineSnapshot( - `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, - ) - expect(result.context).toMatchInlineSnapshot(` - { - "issue": "destructuredWithoutEntryHook", - } - `) - }) - test('multipleParameters', async () => { - // @ts-expect-error - const result = await make(async ({ request }, two) => {}) - expect(result).toMatchInlineSnapshot( - `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, - ) - expect(result.context).toMatchInlineSnapshot(` - { - "issue": "multipleParameters", - } - `) - }) - test('notDestructured', async () => { - const result = await make(async (_) => {}) - expect(result).toMatchInlineSnapshot( - `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, - ) - expect(result.context).toMatchInlineSnapshot(` - { - "issue": "notDestructured", - } - `) - }) - // todo once we have multiple hooks test this case: - // multipleDestructuredHookNames -}) - // todo each extension added should copy, not mutate the client describe(`entrypoint request`, () => { test(`can add header to request`, async ({ fetch }) => { - const client2 = client.extend(async ({ request }) => { + const client2 = client.extend(async ({ pack }) => { // todo should be raw input types but rather resolved // todo should be URL instance? - expectTypeOf(request).toEqualTypeOf() - expect(request.input).toEqual({ url: 'https://foo', document: `query { id \n }` }) - return await request({ ...request.input, headers }) - // todo expose fetch hook + // todo these input type tests should be moved down to Anyware + // expectTypeOf(exchange).toEqualTypeOf() + // expect(exchange.input).toEqual({ url: 'https://foo', document: `query { id \n }` }) + return await pack({ ...pack.input, headers }) }) fetch.mockImplementationOnce(async (input: Request) => { expect(input.headers.get('x-foo')).toEqual(headers['x-foo']) @@ -92,13 +25,10 @@ describe(`entrypoint request`, () => { }) expect(await client2.query.id()).toEqual(db.id) }) - test.only('can chain into fetch', async () => { - const client2 = client.extend(async ({ request }) => { - console.log(1) - const { fetch } = await request({ ...request.input, headers }) - console.log(2) - console.log(fetch) - return await fetch(fetch.input) + test('can chain into exchange', async () => { + const client2 = client.extend(async ({ pack }) => { + const { exchange } = await pack({ ...pack.input, headers }) + return await exchange(exchange.input) }) expect(await client2.query.id()).toEqual(db.id) }) diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index e69eb2de..ec7432cb 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -1,17 +1,17 @@ import { type ExecutionResult } from 'graphql' +import type { Anyware } from '../../lib/anyware/__.js' import type { ErrorAnywareExtensionEntrypoint } from '../../lib/anyware/getEntrypoint.js' import { runExtensions } from '../../lib/anyware/main.js' import { Errors } from '../../lib/errors/__.js' import type { SomeExecutionResultWithoutErrors } from '../../lib/graphql.js' import { type RootTypeName, rootTypeNameToOperationName } from '../../lib/graphql.js' import { isPlainObject } from '../../lib/prelude.js' -import { requestOrExecute, SchemaInput } from '../0_functions/requestOrExecute.js' +import type { SchemaInput } from '../0_functions/requestOrExecute.js' +import { requestOrExecute } from '../0_functions/requestOrExecute.js' import { Schema } from '../1_Schema/__.js' import { readMaybeThunk } from '../1_Schema/core/helpers.js' import type { GlobalRegistry } from '../2_generator/globalRegistry.js' -import { SelectionSet } from '../3_SelectionSet/__.js' import type { Context, DocumentObject, GraphQLObjectSelection } from '../3_SelectionSet/encode.js' -import * as CustomScalars from '../4_ResultSet/customScalars.js' import { Core } from '../5_core/__.js' import type { ApplyInputDefaults, @@ -41,7 +41,7 @@ export type Client<$Index extends Schema.Index | null, $Config extends Config> = : {} // eslint-disable-line ) & { - extend: (extension: Extension) => Client<$Index, $Config> + extend: (extension: Anyware.Extension2) => Client<$Index, $Config> } export type ClientTyped<$Index extends Schema.Index, $Config extends Config> = @@ -273,7 +273,7 @@ export const createInternal = ( }, } - const core = Core.createHttp(context) + const core = Core.create(context) runExtensions({ core: core, diff --git a/src/layers/5_core/__.ts b/src/layers/5_core/__.ts index fa55b580..994058c3 100644 --- a/src/layers/5_core/__.ts +++ b/src/layers/5_core/__.ts @@ -1 +1 @@ -export * as Core from './http.js' +export * as Core from './core.js' diff --git a/src/layers/5_core/http.ts b/src/layers/5_core/core.ts similarity index 96% rename from src/layers/5_core/http.ts rename to src/layers/5_core/core.ts index 7a370490..1db1fa26 100644 --- a/src/layers/5_core/http.ts +++ b/src/layers/5_core/core.ts @@ -44,7 +44,11 @@ type TransportInput = transport: TransportMemory } & B) -export const createCore = (): Core<[`encode`, `pack`, `exchange`, `unpack`, `decode`], { +export const hookSequence = [`encode`, `pack`, `exchange`, `unpack`, `decode`] as const + +export type HookSequence = typeof hookSequence + +export type Hooks = { encode: & InterfaceInput<{}, { document: string | DocumentNode }> & TransportInput<{ schema: string | URL }, { schema: GraphQLSchema }> @@ -79,10 +83,12 @@ export const createCore = (): Core<[`encode`, `pack`, `exchange`, `unpack`, `dec & { result: ExecutionResult } & InterfaceInput // todo this depends on the return mode -}, ExecutionResult> => { +} + +export const create = (): Core => { return { hookNamesOrderedBySequence: [`encode`, `pack`, `exchange`, `unpack`, `decode`], - implementationsByHook: { + hooks: { encode: ( input, ) => { diff --git a/src/lib/anyware/__.ts b/src/lib/anyware/__.ts new file mode 100644 index 00000000..021e9d28 --- /dev/null +++ b/src/lib/anyware/__.ts @@ -0,0 +1 @@ +export * as Anyware from './main.js' diff --git a/src/lib/anyware/main.spec.ts b/src/lib/anyware/main.spec.ts index 4c9467e0..ce1dd905 100644 --- a/src/lib/anyware/main.spec.ts +++ b/src/lib/anyware/main.spec.ts @@ -17,7 +17,7 @@ const createCore = (): $Core => { return { hookNamesOrderedBySequence: [`a`, `b`], - implementationsByHook: { a, b }, + hooks: { a, b }, } } @@ -37,6 +37,72 @@ const run = async (...extensions: ExtensionInput[]) => runWithOptions({})(...ext let core: $Core +// todo +// describe('invalid destructuring cases', () => { +// const make = async (extension: Extension) => +// (await client.extend(extension).query.id()) as any as ErrorAnywareExtensionEntrypoint + +// test('noParameters', async () => { +// const result = await make(async ({}) => {}) +// expect(result).toMatchInlineSnapshot( +// `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, +// ) +// expect(result.context).toMatchInlineSnapshot(` +// { +// "issue": "noParameters", +// } +// `) +// }) +// test('noParameters', async () => { +// const result = await make(async () => {}) +// expect(result).toMatchInlineSnapshot( +// `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, +// ) +// expect(result.context).toMatchInlineSnapshot(` +// { +// "issue": "noParameters", +// } +// `) +// }) +// test('destructuredWithoutEntryHook', async () => { +// // @ts-expect-error +// const result = await make(async ({ request2 }) => {}) +// expect(result).toMatchInlineSnapshot( +// `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, +// ) +// expect(result.context).toMatchInlineSnapshot(` +// { +// "issue": "destructuredWithoutEntryHook", +// } +// `) +// }) +// test('multipleParameters', async () => { +// // @ts-expect-error +// const result = await make(async ({ request }, two) => {}) +// expect(result).toMatchInlineSnapshot( +// `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, +// ) +// expect(result.context).toMatchInlineSnapshot(` +// { +// "issue": "multipleParameters", +// } +// `) +// }) +// test('notDestructured', async () => { +// const result = await make(async (_) => {}) +// expect(result).toMatchInlineSnapshot( +// `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, +// ) +// expect(result.context).toMatchInlineSnapshot(` +// { +// "issue": "notDestructured", +// } +// `) +// }) +// // todo once we have multiple hooks test this case: +// // multipleDestructuredHookNames +// }) + describe(`no extensions`, () => { test(`passthrough to implementation`, async () => { const result = await run() @@ -53,8 +119,8 @@ describe(`one extension`, () => { return 0 }), ).toEqual(0) - expect(core.implementationsByHook.a).toHaveBeenCalled() - expect(core.implementationsByHook.b).toHaveBeenCalled() + expect(core.hooks.a).toHaveBeenCalled() + expect(core.hooks.b).toHaveBeenCalled() }) describe(`can short-circuit`, () => { test(`at start, return input`, async () => { @@ -64,8 +130,8 @@ describe(`one extension`, () => { return a.input }), ).toEqual({ value: `initial` }) - expect(core.implementationsByHook.a).not.toHaveBeenCalled() - expect(core.implementationsByHook.b).not.toHaveBeenCalled() + expect(core.hooks.a).not.toHaveBeenCalled() + expect(core.hooks.b).not.toHaveBeenCalled() }) test(`at start, return own result`, async () => { expect( @@ -74,8 +140,8 @@ describe(`one extension`, () => { return 0 }), ).toEqual(0) - expect(core.implementationsByHook.a).not.toHaveBeenCalled() - expect(core.implementationsByHook.b).not.toHaveBeenCalled() + expect(core.hooks.a).not.toHaveBeenCalled() + expect(core.hooks.b).not.toHaveBeenCalled() }) test(`after first hook, return own result`, async () => { expect( @@ -84,7 +150,7 @@ describe(`one extension`, () => { return b.input.value + `+x` }), ).toEqual(`initial+a+x`) - expect(core.implementationsByHook.b).not.toHaveBeenCalled() + expect(core.hooks.b).not.toHaveBeenCalled() }) }) describe(`can partially apply`, () => { @@ -120,8 +186,8 @@ describe(`two extensions`, () => { const ex2 = vi.fn().mockImplementation(() => 2) expect(await run(ex1, ex2)).toEqual(1) expect(ex2).not.toHaveBeenCalled() - expect(core.implementationsByHook.a).not.toHaveBeenCalled() - expect(core.implementationsByHook.b).not.toHaveBeenCalled() + expect(core.hooks.a).not.toHaveBeenCalled() + expect(core.hooks.b).not.toHaveBeenCalled() }) test(`each can adjust first hook then passthrough`, async () => { @@ -163,8 +229,8 @@ describe(`two extensions`, () => { } expect(await run(ex1, ex2)).toEqual(2) expect(ex1AfterA).toBe(false) - expect(core.implementationsByHook.a).not.toHaveBeenCalled() - expect(core.implementationsByHook.b).not.toHaveBeenCalled() + expect(core.hooks.a).not.toHaveBeenCalled() + expect(core.hooks.b).not.toHaveBeenCalled() }) test(`second can short-circuit after hook a`, async () => { let ex1AfterB = false @@ -179,8 +245,8 @@ describe(`two extensions`, () => { } expect(await run(ex1, ex2)).toEqual(2) expect(ex1AfterB).toBe(false) - expect(core.implementationsByHook.a).toHaveBeenCalledOnce() - expect(core.implementationsByHook.b).not.toHaveBeenCalled() + expect(core.hooks.a).toHaveBeenCalledOnce() + expect(core.hooks.b).not.toHaveBeenCalled() }) }) diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts index baba5670..c4c830ff 100644 --- a/src/lib/anyware/main.ts +++ b/src/lib/anyware/main.ts @@ -1,3 +1,9 @@ +// todo allow hooks to have implementation overriden. +// E.g.: request((input) => {...}) +// todo allow hooks to have passthrough without explicit input passing +// E.g.: NOT await request(request.input) +// but instead simply: await request() + import type { Deferred, FindValueAfter, @@ -9,17 +15,76 @@ import type { import { casesExhausted, createDeferred, debug } from '../prelude.js' import { getEntrypoint } from './getEntrypoint.js' +type HookSequence = readonly [string, ...string[]] +export type Extension2< + $HookSequence extends HookSequence, + $HookMap extends Record<$HookSequence[number], object> = Record<$HookSequence[number], object>, + $Result = unknown, +> = ( + hooks: ExtensionHooks< + $HookSequence, + $HookMap, + $Result + >, +) => Promise< + | $Result + | SomeHookEnvelope +> + +type ExtensionHooks< + $HookSequence extends HookSequence, + $HookMap extends Record<$HookSequence[number], object> = Record<$HookSequence[number], object>, + $Result = unknown, +> = { + [$HookName in $HookSequence[number]]: Hook<$HookSequence, $HookMap, $Result, $HookName> +} + +const hookSymbol = Symbol(`hook`) + +type HookSymbol = typeof hookSymbol + +type SomeHookEnvelope = { + [name: string]: SomeHook +} +type SomeHook = { + [hookSymbol]: HookSymbol +} + +type Hook< + $HookSequence extends HookSequence, + $HookMap extends Record<$HookSequence[number], object> = Record<$HookSequence[number], object>, + $Result = unknown, + $Name extends $HookSequence[number] = $HookSequence[number], +> = (<$$Input extends $HookMap[$Name]>(input: $$Input) => HookReturn<$HookSequence, $HookMap, $Result, $Name>) & { + [hookSymbol]: HookSymbol + input: $HookMap[$Name] +} + +type HookReturn< + $HookSequence extends HookSequence, + $HookMap extends Record<$HookSequence[number], object> = Record<$HookSequence[number], object>, + $Result = unknown, + $Name extends $HookSequence[number] = $HookSequence[number], +> = IsLastValue<$Name, $HookSequence> extends true ? $Result : { + [$NameNext in FindValueAfter<$Name, $HookSequence>]: Hook< + $HookSequence, + $HookMap, + $Result, + $NameNext + > +} + export type Core< - $Hooks extends [string, ...string[]], - $HookMap extends Record<$Hooks[number], object> = Record<$Hooks[number], object>, + $HookSequence extends readonly [string, ...string[]], + $HookMap extends Record<$HookSequence[number], object> = Record<$HookSequence[number], object>, $Result = unknown, > = { - hookNamesOrderedBySequence: $Hooks - implementationsByHook: { - [$HookName in $Hooks[number]]: ( + hookNamesOrderedBySequence: $HookSequence + hooks: { + [$HookName in $HookSequence[number]]: ( input: $HookMap[$HookName], ) => MaybePromise< - IsLastValue<$HookName, $Hooks> extends true ? $Result : $HookMap[FindValueAfter<$HookName, $Hooks>] + IsLastValue<$HookName, $HookSequence> extends true ? $Result : $HookMap[FindValueAfter<$HookName, $HookSequence>] > } } diff --git a/src/lib/anyware/snippet.ts b/src/lib/anyware/snippet.ts deleted file mode 100644 index 713b7332..00000000 --- a/src/lib/anyware/snippet.ts +++ /dev/null @@ -1,24 +0,0 @@ -const somethingBetter = something.extend(({ foo }) => { - // do whatever you want before foo - const { bar } = await foo({ ...foo.input, customThing: true }) - // do whatever you want after foo/before bar - const result = await bar({ ...bar.input, moarCustom: true }) - // do whatever you want after foo - return result -}) - -// -- Don't need anything before bar? skip it! - -const somethingBetter = something.extend(({ bar }) => { - // do whatever you want before bar - const result = await bar({ ...bar.input, moarCustom: true }) - // do whatever you want after foo - return result -}) - -// -- Don't need anything after foo? skip it! - -const somethingBetter = something.extend(({ foo }) => { - // do whatever you want before foo - return await foo({ ...foo.input, customThing: true }) -}) diff --git a/src/lib/prelude.ts b/src/lib/prelude.ts index 47a5174c..b111870c 100644 --- a/src/lib/prelude.ts +++ b/src/lib/prelude.ts @@ -293,19 +293,20 @@ export type MinusOneUpToTen = n extends 10 ? 9 : n extends 1 ? 0 : never -export type findIndexForValue = findIndexForValue_ -type findIndexForValue_ = value extends list[i] ? i +export type findIndexForValue = findIndexForValue_ +type findIndexForValue_ = value extends list[i] ? i : findIndexForValue_> -export type FindValueAfter = list[PlusOneUpToTen>] +export type FindValueAfter = + list[PlusOneUpToTen>] export type ValueOr = value extends undefined ? orValue : value -export type FindValueAfterOr = ValueOr< +export type FindValueAfterOr = ValueOr< list[PlusOneUpToTen>], orValue > -export type GetLastValue = T[MinusOneUpToTen] +export type GetLastValue = T[MinusOneUpToTen] -export type IsLastValue = value extends GetLastValue ? true : false +export type IsLastValue = value extends GetLastValue ? true : false From 952b575ef7d6eaea532bf6a45e67af88899eee76 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 20 May 2024 11:04:55 -0400 Subject: [PATCH 11/30] integrating into client --- extension sketch.md | 4 +- src/layers/5_client/client.ts | 54 +++++++++++++++++++------ src/layers/5_core/core.ts | 74 ++++++++++++++++++++--------------- src/lib/anyware/main.spec.ts | 4 +- src/lib/anyware/main.ts | 26 ++++++++---- 5 files changed, 107 insertions(+), 55 deletions(-) diff --git a/extension sketch.md b/extension sketch.md index 80772800..f453d8e3 100644 --- a/extension sketch.md +++ b/extension sketch.md @@ -28,7 +28,7 @@ const getFirstHook = () => hookNamesOrderedBySequence[0] const getNextHook = (hookName) => hookNamesOrderedBySequence[hookIndexes[hookName]+1] ?? null const core = { - implementationsByHook: { + hooks: { request: (input) => {}, fetch: (input) => {}, } @@ -76,7 +76,7 @@ const runHook = ({ core, name, done, originalInput, currentHookStack, nextHookSt // Run core to get result - const implementation = core.implementationsByHook[name] + const implementation = core.hooks[name] const result = await implementation(originalInput) // Return to root with the next result and hook stack diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index ec7432cb..8ebbcd0e 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -1,7 +1,6 @@ -import { type ExecutionResult } from 'graphql' -import type { Anyware } from '../../lib/anyware/__.js' +import { type ExecutionResult, GraphQLSchema } from 'graphql' +import { Anyware } from '../../lib/anyware/__.js' import type { ErrorAnywareExtensionEntrypoint } from '../../lib/anyware/getEntrypoint.js' -import { runExtensions } from '../../lib/anyware/main.js' import { Errors } from '../../lib/errors/__.js' import type { SomeExecutionResultWithoutErrors } from '../../lib/graphql.js' import { type RootTypeName, rootTypeNameToOperationName } from '../../lib/graphql.js' @@ -13,6 +12,7 @@ import { readMaybeThunk } from '../1_Schema/core/helpers.js' import type { GlobalRegistry } from '../2_generator/globalRegistry.js' import type { Context, DocumentObject, GraphQLObjectSelection } from '../3_SelectionSet/encode.js' import { Core } from '../5_core/__.js' +import type { HookInputEncode } from '../5_core/core.js' import type { ApplyInputDefaults, Config, @@ -152,6 +152,8 @@ export const createInternal = ( */ const returnMode = input.returnMode ?? `data` as ReturnModeType + const core = Core.create() + // const executeRootType = // (context: Context, rootTypeName: RootTypeName) => // async (selection: GraphQLObjectSelection): Promise => { @@ -175,7 +177,42 @@ export const createInternal = ( : argsOrSelectionSet, }, } as GraphQLObjectSelection - const result = await executeRootType(context, rootTypeName)(documentObject) + + const transport = input.schema instanceof GraphQLSchema ? `memory` : `http` + const initialInput: HookInputEncode = transport === `http` + ? { + interface: `typed`, + selection: documentObject, + context: { + config: context.config, + transport, + interface: `typed`, + schemaIndex: context.schemaIndex, + }, + transport, + rootTypeName, + schema: input.schema as string | URL, + } + : { + interface: `typed`, + selection: documentObject, + context: { + config: context.config, + transport, + interface: `typed`, + schemaIndex: context.schemaIndex, + }, + transport, + rootTypeName, + schema: input.schema as GraphQLSchema, + } + + const result = await Anyware.runWithExtensions({ + core, + initialInput, + extensions: [], + options: {}, + }) const resultHandled = handleReturn(context.schemaIndex, result, returnMode) if (resultHandled instanceof Error) return resultHandled return returnMode === `data` || returnMode === `dataAndErrors` || returnMode === `successData` @@ -273,15 +310,6 @@ export const createInternal = ( }, } - const core = Core.create(context) - - runExtensions({ - core: core, - extensions: [], - initialInput: {}, - options: {}, - }) - Object.assign(client, { document: (documentObject: DocumentObject) => { const run = async (operationName: string) => { diff --git a/src/layers/5_core/core.ts b/src/layers/5_core/core.ts index 1db1fa26..88d8dfee 100644 --- a/src/layers/5_core/core.ts +++ b/src/layers/5_core/core.ts @@ -8,6 +8,7 @@ import { casesExhausted } from '../../lib/prelude.js' import { execute } from '../0_functions/execute.js' import type { Schema } from '../1_Schema/__.js' import { SelectionSet } from '../3_SelectionSet/__.js' +import type { GraphQLObjectSelection } from '../3_SelectionSet/encode.js' import * as Result from '../4_ResultSet/customScalars.js' import type { ContextInterfaceRaw, @@ -48,44 +49,52 @@ export const hookSequence = [`encode`, `pack`, `exchange`, `unpack`, `decode`] a export type HookSequence = typeof hookSequence -export type Hooks = { - encode: - & InterfaceInput<{}, { document: string | DocumentNode }> - & TransportInput<{ schema: string | URL }, { schema: GraphQLSchema }> - pack: - & { +export type HookInputEncode = + & InterfaceInput<{ selection: GraphQLObjectSelection }, { document: string | DocumentNode }> + & TransportInput<{ schema: string | URL }, { schema: GraphQLSchema }> + +export type HookInputPack = + & { + document: string | DocumentNode + variables: StandardScalarVariables + operationName?: string + } + & InterfaceInput + & TransportInput<{ url: string | URL; headers?: HeadersInit }, { schema: GraphQLSchema }> +export type ExchangeInputHook = + & InterfaceInput + & TransportInput< + { request: Request }, + { + schema: GraphQLSchema document: string | DocumentNode variables: StandardScalarVariables operationName?: string } - & InterfaceInput - & TransportInput<{ url: string | URL; headers?: HeadersInit }, { schema: GraphQLSchema }> - exchange: - & InterfaceInput - & TransportInput< - { request: Request }, - { - schema: GraphQLSchema - document: string | DocumentNode - variables: StandardScalarVariables - operationName?: string - } - > - unpack: - & InterfaceInput - & TransportInput< - { response: Response }, - { - result: ExecutionResult - } - > - decode: - & { result: ExecutionResult } - & InterfaceInput - // todo this depends on the return mode + > +export type HookInputUnpack = + & InterfaceInput + & TransportInput< + { response: Response }, + { + result: ExecutionResult + } + > +export type HookInputDecode = + & { result: ExecutionResult } + & InterfaceInput + +export type Hooks = { + encode: HookInputEncode + pack: HookInputPack + exchange: ExchangeInputHook + unpack: HookInputUnpack + decode: HookInputDecode } +// todo does this need to be a constructor? export const create = (): Core => { + // todo Get type passing by having a constructor brand the result return { hookNamesOrderedBySequence: [`encode`, `pack`, `exchange`, `unpack`, `decode`], hooks: { @@ -236,5 +245,8 @@ export const create = (): Core => { } }, }, + // 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... } } diff --git a/src/lib/anyware/main.spec.ts b/src/lib/anyware/main.spec.ts index ce1dd905..e40b124e 100644 --- a/src/lib/anyware/main.spec.ts +++ b/src/lib/anyware/main.spec.ts @@ -1,7 +1,7 @@ import type { Mock } from 'vitest' import { describe, expect, test, vi } from 'vitest' import type { Core, ExtensionInput, Options } from './main.js' -import { runExtensions } from './main.js' +import { runWithExtensions } from './main.js' type Input = { value: string } @@ -24,7 +24,7 @@ const createCore = (): $Core => { const runWithOptions = (options: Options = {}) => async (...extensions: ExtensionInput[]) => { core = createCore() - const result = await runExtensions({ + const result = await runWithExtensions({ core, initialInput: { value: `initial` }, extensions, diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts index c4c830ff..43b721b9 100644 --- a/src/lib/anyware/main.ts +++ b/src/lib/anyware/main.ts @@ -39,6 +39,13 @@ type ExtensionHooks< [$HookName in $HookSequence[number]]: Hook<$HookSequence, $HookMap, $Result, $HookName> } +type CoreInitialInput<$Core extends Core> = + $Core[PrivateTypesSymbol]['hookMap'][$Core[PrivateTypesSymbol]['hookSequence'][0]] + +const PrivateTypesSymbol = Symbol(`private`) + +type PrivateTypesSymbol = typeof PrivateTypesSymbol + const hookSymbol = Symbol(`hook`) type HookSymbol = typeof hookSymbol @@ -75,10 +82,15 @@ type HookReturn< } export type Core< - $HookSequence extends readonly [string, ...string[]], + $HookSequence extends HookSequence = HookSequence, $HookMap extends Record<$HookSequence[number], object> = Record<$HookSequence[number], object>, $Result = unknown, > = { + [PrivateTypesSymbol]: { + hookSequence: $HookSequence + hookMap: $HookMap + result: $Result + } hookNamesOrderedBySequence: $HookSequence hooks: { [$HookName in $HookSequence[number]]: ( @@ -202,7 +214,7 @@ const runHook = async <$HookName extends string>( // Run core to get result - const implementation = core.implementationsByHook[name] + const implementation = core.hooks[name] const result = await implementation(originalInput) // Return to root with the next result and hook stack @@ -279,7 +291,7 @@ const toInternalExtension = (core: Core, config: Config, extension: SomeAsyncFun currentChunk.promise.then(appplyBody) return { name: extension.name, - entrypoint: core.hookNamesOrderedBySequence[0]!, // todo non-empty-array datastructure + entrypoint: core.hookNamesOrderedBySequence[0], // todo non-empty-array datastructure body, currentChunk, } @@ -295,7 +307,7 @@ const toInternalExtension = (core: Core, config: Config, extension: SomeAsyncFun currentChunk.promise.then(appplyBody) return { name: extension.name, - entrypoint: core.hookNamesOrderedBySequence[0]!, // todo non-empty-array datastructure + entrypoint: core.hookNamesOrderedBySequence[0], // todo non-empty-array datastructure body, currentChunk, } @@ -341,10 +353,10 @@ export type Options = { entrypointSelectionMode?: 'optional' | 'required' | 'off' } -export const runExtensions = async ( +export const runWithExtensions = async <$Core extends Core>( { core, initialInput, extensions, options }: { - core: Core - initialInput: any + core: $Core + initialInput: CoreInitialInput<$Core> extensions: ExtensionInput[] options?: Options }, From 2b52cd459e1e1ee28d74b78dcf4b7e9b86e37677 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 20 May 2024 11:27:01 -0400 Subject: [PATCH 12/30] first working tested integration --- extension sketch.md | 146 ---------------------- src/layers/5_client/client.extend.test.ts | 16 ++- src/layers/5_client/client.ts | 4 +- src/layers/5_core/core.ts | 7 +- src/lib/anyware/main.ts | 21 +++- 5 files changed, 33 insertions(+), 161 deletions(-) delete mode 100644 extension sketch.md diff --git a/extension sketch.md b/extension sketch.md deleted file mode 100644 index f453d8e3..00000000 --- a/extension sketch.md +++ /dev/null @@ -1,146 +0,0 @@ -```ts -// ------- base library -------- - -const createDeferred = () => { - let result, reject - const promise = new Promise(($resolve,$reject) => { - resolve = resolve - reject = reject - }) - return { - promise, - resolve, - reject - } -} - -// ------- system -------- - -const hookNamesOrderedBySequence = ['request', 'fetch'] - -const hookIndexes = { - request: 0, - fetch: 1, -} - -const getFirstHook = () => hookNamesOrderedBySequence[0] - -const getNextHook = (hookName) => hookNamesOrderedBySequence[hookIndexes[hookName]+1] ?? null - -const core = { - hooks: { - request: (input) => {}, - fetch: (input) => {}, - } -} - - - -const runHook = ({ core, name, done, originalInput, currentHookStack, nextHookStack }) => { - const [pausedExtension, ...nextCurrentHookStack] = currentHookStack - - // Going down the stack - // -------------------- - - if (pausedExtension) { - // The extension is responsible for calling the next hook. - const hook = withOriginalInput(originalInput, (nextOriginalInput) => { - // Once called, the extension is paused again and we continue down the current hook stack. - - const pausedExtension = createDeferred() - const nextNextHookStack = [...nextHookStack, pausedExtension] // tempting to mutate here but simpler to think about as copy. - - runHook({ - core, - name, - done, - originalInput: nextOriginalInput, - currentHookStack: nextCurrentHookStack, - nextHookStack: nextNextHookStack, - }) - - return pausedExtension.promise - }) - - - // The extension is resumed. It is responsible for calling the next hook. - - const envelope = { [name]: hook } - pausedExtension.resolve(envelope) - - return - } - - // Reached bottom of the stack - // --------------------------- - - // Run core to get result - - const implementation = core.hooks[name] - const result = await implementation(originalInput) - - // Return to root with the next result and hook stack - - done({ result, nextHookStack }) - - return -} - - - -const run = ({ core, initialInput, initialHookStack }) => { - let currentInput = initialInput - let currentHookStack = initialHookStack - - for (hookName of hookNamesOrderedBySequence) { - const doneDeferred = createDeferred() - runHook({ - core, - name: hookName, - done: doneDeferred.resolve, - originalInput: currentInput, - currentHookStack, - nextHookStack: [], - }) - const { result, nextHookStack } = await doneDeferred.promise - currentInput = result - currentHookStack = nextHookStack - } - - return currentInput // last loop result -} - - - -const runExtensions = (extensions) => { - return await run({ - core, - {}, // first hook input - extensions.map(fn => { - const deferred = createDeferred() - deferred.promise.then(fn) - return deferred.resolve - }) - }) -} - - - -// ----- application ----- - - - -const extensionA = ({ request/*START*/ }) => { - const { fetch }/*B2*/ = await request(request.input)/*A1*/ - const { value }/*D2*/ = await fetch(fetch.input)/*C1*/ - return value/*E1*/ -} - -const extensionB = ({ request/*A2*/ }) => { - const { fetch }/*C2*/ = await request(request.input)/*B1*/ - const { value }/*E2*/ = await fetch(fetch.input)/*D1*/ - return value/*END*/ -} - -runExtensions([extensionA, extensionB]) -``` diff --git a/src/layers/5_client/client.extend.test.ts b/src/layers/5_client/client.extend.test.ts index ee0876c9..f5c721fa 100644 --- a/src/layers/5_client/client.extend.test.ts +++ b/src/layers/5_client/client.extend.test.ts @@ -11,6 +11,10 @@ const headers = { 'x-foo': 'bar' } describe(`entrypoint request`, () => { test(`can add header to request`, async ({ fetch }) => { + fetch.mockImplementationOnce(async (input: Request) => { + expect(input.headers.get('x-foo')).toEqual(headers['x-foo']) + return createResponse({ data: { id: db.id } }) + }) const client2 = client.extend(async ({ pack }) => { // todo should be raw input types but rather resolved // todo should be URL instance? @@ -19,13 +23,12 @@ describe(`entrypoint request`, () => { // expect(exchange.input).toEqual({ url: 'https://foo', document: `query { id \n }` }) return await pack({ ...pack.input, headers }) }) + expect(await client2.query.id()).toEqual(db.id) + }) + test('can chain into exchange', async ({ fetch }) => { fetch.mockImplementationOnce(async (input: Request) => { - expect(input.headers.get('x-foo')).toEqual(headers['x-foo']) return createResponse({ data: { id: db.id } }) }) - expect(await client2.query.id()).toEqual(db.id) - }) - test('can chain into exchange', async () => { const client2 = client.extend(async ({ pack }) => { const { exchange } = await pack({ ...pack.input, headers }) return await exchange(exchange.input) @@ -33,8 +36,3 @@ describe(`entrypoint request`, () => { expect(await client2.query.id()).toEqual(db.id) }) }) - -describe.todo(`entrypoint fetch`, () => { -}) - -// todo test a throw from an extension diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index 8ebbcd0e..cc43ebf2 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -1,3 +1,4 @@ +import { stat } from 'fs' import { type ExecutionResult, GraphQLSchema } from 'graphql' import { Anyware } from '../../lib/anyware/__.js' import type { ErrorAnywareExtensionEntrypoint } from '../../lib/anyware/getEntrypoint.js' @@ -210,9 +211,10 @@ export const createInternal = ( const result = await Anyware.runWithExtensions({ core, initialInput, - extensions: [], + extensions: state.extensions, options: {}, }) + const resultHandled = handleReturn(context.schemaIndex, result, returnMode) if (resultHandled instanceof Error) return resultHandled return returnMode === `data` || returnMode === `dataAndErrors` || returnMode === `successData` diff --git a/src/layers/5_core/core.ts b/src/layers/5_core/core.ts index 88d8dfee..eefb0142 100644 --- a/src/layers/5_core/core.ts +++ b/src/layers/5_core/core.ts @@ -1,7 +1,7 @@ import type { DocumentNode, ExecutionResult, GraphQLSchema } from 'graphql' import { print } from 'graphql' import type { Core } from '../../lib/anyware/main.js' -import type { StandardScalarVariables } from '../../lib/graphql.js' +import { rootTypeNameToOperationName, type StandardScalarVariables } from '../../lib/graphql.js' import { parseExecutionResult } from '../../lib/graphqlHTTP.js' import { CONTENT_TYPE_GQL } from '../../lib/http.js' import { casesExhausted } from '../../lib/prelude.js' @@ -101,6 +101,7 @@ export const create = (): Core => { encode: ( input, ) => { + // console.log(`encode:1`) let document: string | DocumentNode switch (input.interface) { case `raw`: { @@ -113,7 +114,7 @@ export const create = (): Core => { input.context, getRootIndexOrThrow(input.context, input.rootTypeName), // @ts-expect-error fixme - selection[rootTypeNameToOperationName[input.rootTypeName]], + input.selection[rootTypeNameToOperationName[input.rootTypeName]], ) break } @@ -121,6 +122,7 @@ export const create = (): Core => { throw casesExhausted(input) } + // console.log(`encode:2`) switch (input.transport) { case `http`: { return { @@ -145,6 +147,7 @@ export const create = (): Core => { } }, pack: (input) => { + // console.log(`pack:1`) const documentPrinted = typeof input.document === `string` ? input.document : print(input.document) diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts index 43b721b9..1429dbb3 100644 --- a/src/lib/anyware/main.ts +++ b/src/lib/anyware/main.ts @@ -4,6 +4,7 @@ // E.g.: NOT await request(request.input) // but instead simply: await request() +import { ContextualError } from '../errors/ContextualError.js' import type { Deferred, FindValueAfter, @@ -12,7 +13,7 @@ import type { SomeAsyncFunction, SomeMaybeAsyncFunction, } from '../prelude.js' -import { casesExhausted, createDeferred, debug } from '../prelude.js' +import { casesExhausted, createDeferred, debug, errorFromMaybeError } from '../prelude.js' import { getEntrypoint } from './getEntrypoint.js' type HookSequence = readonly [string, ...string[]] @@ -125,6 +126,7 @@ export type ExtensionInput = SomeMaybeAsyncFunction type HookDoneData = | { type: 'completed'; result: unknown; nextHookStack: Extension[] } | { type: 'shortCircuited'; result: unknown } + | { type: 'error'; error: Error; source: 'implementation'; hookName: string } type HookDoneResolver = (input: HookDoneData) => void @@ -215,12 +217,17 @@ const runHook = async <$HookName extends string>( // Run core to get result const implementation = core.hooks[name] - const result = await implementation(originalInput) + let result + try { + result = await implementation(originalInput) + } catch (error) { + done({ type: `error`, error: errorFromMaybeError(error), source: `implementation`, hookName: name }) + return + } // Return to root with the next result and hook stack done({ type: `completed`, result, nextHookStack }) - return } @@ -252,9 +259,17 @@ const run = async ( break } case `shortCircuited`: { + debug(`signal: shortCircuited`) const { result } = signal return result } + case `error`: { + debug(`signal: error`) + // todo constructor error lower in stack for better trace? + // todo return? + const message = `There was an error in the core implementation of hook "${signal.hookName}".` + throw new ContextualError(message, { hookName: signal.hookName }, signal.error) + } default: casesExhausted(signal) } From dded27fb26609a71b576314ad1279b4755971594 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 20 May 2024 12:53:55 -0400 Subject: [PATCH 13/30] work --- .../5_client/client.rootTypeMethods.test.ts | 16 - src/layers/5_client/client.ts | 342 ++++++++++-------- 2 files changed, 184 insertions(+), 174 deletions(-) diff --git a/src/layers/5_client/client.rootTypeMethods.test.ts b/src/layers/5_client/client.rootTypeMethods.test.ts index 9e748525..b6d2b099 100644 --- a/src/layers/5_client/client.rootTypeMethods.test.ts +++ b/src/layers/5_client/client.rootTypeMethods.test.ts @@ -44,20 +44,4 @@ describe(`query`, () => { await expect(graffle.query.errorOrThrow()).rejects.toMatchObject(db.errorAggregate) }) }) - describe(`$batch`, () => { - test(`success`, async () => { - await expect(graffle.query.$batch({ id: true })).resolves.toMatchObject({ id:db.id }) - }) - test(`error`, async () => { - await expect(graffle.query.$batch({ error: true })).rejects.toMatchObject(db.errorAggregate) - }) - describe(`orThrow`, () => { - test(`success`, async () => { - await expect(graffle.query.$batchOrThrow({ id: true })).resolves.toMatchObject({ id:db.id }) - }) - test(`error`, async () => { - await expect(graffle.query.$batchOrThrow({ error: true })).rejects.toMatchObject(db.errorAggregate) - }) - }) - }) }) diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index cc43ebf2..eca1f122 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -1,4 +1,3 @@ -import { stat } from 'fs' import { type ExecutionResult, GraphQLSchema } from 'graphql' import { Anyware } from '../../lib/anyware/__.js' import type { ErrorAnywareExtensionEntrypoint } from '../../lib/anyware/getEntrypoint.js' @@ -7,13 +6,15 @@ import type { SomeExecutionResultWithoutErrors } from '../../lib/graphql.js' import { type RootTypeName, rootTypeNameToOperationName } from '../../lib/graphql.js' import { isPlainObject } from '../../lib/prelude.js' import type { SchemaInput } from '../0_functions/requestOrExecute.js' -import { requestOrExecute } from '../0_functions/requestOrExecute.js' +// import { requestOrExecute } from '../0_functions/requestOrExecute.js' +import type { Input as RawInput } from '../0_functions/requestOrExecute.js' import { Schema } from '../1_Schema/__.js' import { readMaybeThunk } from '../1_Schema/core/helpers.js' import type { GlobalRegistry } from '../2_generator/globalRegistry.js' -import type { Context, DocumentObject, GraphQLObjectSelection } from '../3_SelectionSet/encode.js' +import type { DocumentObject, GraphQLObjectSelection } from '../3_SelectionSet/encode.js' import { Core } from '../5_core/__.js' import type { HookInputEncode } from '../5_core/core.js' +import type { InterfaceRaw } from '../5_core/types.js' import type { ApplyInputDefaults, Config, @@ -25,7 +26,18 @@ import type { DocumentFn } from './document.js' import { toDocumentString } from './document.js' import type { GetRootTypeMethods } from './RootTypeMethods.js' -type RawInput = Omit +export interface Context { + core: any // todo + extensions: Extension[] + schemaIndex?: Schema.Index + config: { + returnMode: ReturnModeType + } +} + +export type TypedContext = Omit & { + schemaIndex: Schema.Index +} // todo no config needed? export type ClientRaw<_$Config extends Config> = { @@ -153,140 +165,149 @@ export const createInternal = ( */ const returnMode = input.returnMode ?? `data` as ReturnModeType - const core = Core.create() - - // const executeRootType = - // (context: Context, rootTypeName: RootTypeName) => - // async (selection: GraphQLObjectSelection): Promise => { - // } - - const executeRootTypeField = (context: Context, rootTypeName: RootTypeName, key: string) => { - return async (argsOrSelectionSet?: object) => { - const type = readMaybeThunk( - // eslint-disable-next-line - // @ts-ignore excess depth error - Schema.Output.unwrapToNamed(readMaybeThunk(input.schemaIndex.Root[rootTypeName]?.fields[key]?.type)), - ) as Schema.Output.Named - if (!type) throw new Error(`${rootTypeName} field not found: ${String(key)}`) // eslint-disable-line - // @ts-expect-error fixme - const isSchemaScalarOrTypeName = type.kind === `Scalar` || type.kind === `typename` // todo fix type here, its valid - const isSchemaHasArgs = Boolean(context.schemaIndex.Root[rootTypeName]?.fields[key]?.args) - const documentObject = { - [rootTypeNameToOperationName[rootTypeName]]: { - [key]: isSchemaScalarOrTypeName - ? isSchemaHasArgs && argsOrSelectionSet ? { $: argsOrSelectionSet } : true - : argsOrSelectionSet, - }, - } as GraphQLObjectSelection + const executeRootTypeField = async ( + context: TypedContext, + rootTypeName: RootTypeName, + key: string, + argsOrSelectionSet?: object, + ) => { + const type = readMaybeThunk( + // eslint-disable-next-line + // @ts-ignore excess depth error + Schema.Output.unwrapToNamed(readMaybeThunk(input.schemaIndex.Root[rootTypeName]?.fields[key]?.type)), + ) as Schema.Output.Named + if (!type) throw new Error(`${rootTypeName} field not found: ${String(key)}`) // eslint-disable-line + // @ts-expect-error fixme + const isSchemaScalarOrTypeName = type.kind === `Scalar` || type.kind === `typename` // todo fix type here, its valid + const isSchemaHasArgs = Boolean(context.schemaIndex.Root[rootTypeName]?.fields[key]?.args) + const documentObject = { + [rootTypeNameToOperationName[rootTypeName]]: { + [key]: isSchemaScalarOrTypeName + ? isSchemaHasArgs && argsOrSelectionSet ? { $: argsOrSelectionSet } : true + : argsOrSelectionSet, + }, + } as GraphQLObjectSelection + const result = await executeRootType(context, rootTypeName, documentObject) + return context.config.returnMode === `data` || context.config.returnMode === `dataAndErrors` + || context.config.returnMode === `successData` + // @ts-expect-error + ? result[key] + : result + } - const transport = input.schema instanceof GraphQLSchema ? `memory` : `http` - const initialInput: HookInputEncode = transport === `http` - ? { - interface: `typed`, - selection: documentObject, - context: { - config: context.config, - transport, - interface: `typed`, - schemaIndex: context.schemaIndex, - }, - transport, - rootTypeName, - schema: input.schema as string | URL, - } - : { - interface: `typed`, - selection: documentObject, - context: { - config: context.config, - transport, - interface: `typed`, - schemaIndex: context.schemaIndex, - }, - transport, - rootTypeName, - schema: input.schema as GraphQLSchema, - } + const executeRootType = async ( + context: TypedContext, + rootTypeName: RootTypeName, + selectionSet: GraphQLObjectSelection, + ) => { + const transport = input.schema instanceof GraphQLSchema ? `memory` : `http` + const interface_ = `typed` + const initialInput = { + interface: interface_, + selection: selectionSet, + context: { + config: context.config, + transport, + interface: interface_, + schemaIndex: context.schemaIndex, + }, + transport, + rootTypeName, + schema: input.schema as string | URL, + } + const result = await run(context, initialInput) + // todo centralize + return handleReturn(context, result) + } - const result = await Anyware.runWithExtensions({ - core, - initialInput, - extensions: state.extensions, - options: {}, + const createRootType = (context: TypedContext, rootTypeName: RootTypeName) => { + return async (isOrThrow: boolean, selectionSetOrIndicator: GraphQLObjectSelection) => { + const context2 = isOrThrow ? { ...context, config: { ...context.config, returnMode: `successData` } } : context + return await executeRootType(context2, rootTypeName, { + [rootTypeNameToOperationName[rootTypeName]]: selectionSetOrIndicator, }) + } + } - const resultHandled = handleReturn(context.schemaIndex, result, returnMode) - if (resultHandled instanceof Error) return resultHandled - return returnMode === `data` || returnMode === `dataAndErrors` || returnMode === `successData` - // @ts-expect-error make this type safe? - ? resultHandled[key] - : resultHandled + const createRootTypeField = (context: TypedContext, rootTypeName: RootTypeName) => { + return async (isOrThrow: boolean, fieldName: string, argsOrSelectionSet?: object) => { + const result = await executeRootTypeField(context, rootTypeName, fieldName, argsOrSelectionSet) // eslint-disable-line + // todo all of the following is return processing, could be lifted out & centralized + if (isOrThrow && result instanceof Error) throw result + // todo consolidate + // eslint-disable-next-line + if (isOrThrow && returnMode === `graphql` && result.errors.length > 0) { + throw new Errors.ContextualAggregateError( + `One or more errors in the execution result.`, + {}, + // eslint-disable-next-line + result.errors, + ) + } + return result } } - const createRootTypeMethods = (context: Context, rootTypeName: RootTypeName) => - new Proxy({}, { + const createRootTypeMethods = (context: TypedContext, rootTypeName: RootTypeName) => { + const rootType = createRootType(context, rootTypeName) + const rootTypeField = createRootTypeField(context, rootTypeName) + return new Proxy({}, { get: (_, key) => { if (typeof key === `symbol`) throw new Error(`Symbols not supported.`) + // todo centralize the orthrow handling here rather than into each method // todo We need to document that in order for this to 100% work none of the user's root type fields can end with "OrThrow". const isOrThrow = key.endsWith(`OrThrow`) if (key.startsWith(`$batch`)) { - return async (selectionSetOrIndicator: GraphQLObjectSelection) => { - const resultRaw = await executeRootType(context, rootTypeName)({ - [rootTypeNameToOperationName[rootTypeName]]: selectionSetOrIndicator, - }) - const result = handleReturn(context.schemaIndex, resultRaw, returnMode) - if (isOrThrow && result instanceof Error) throw result - // todo consolidate - // @ts-expect-error fixme - if (isOrThrow && returnMode === `graphql` && result.errors && result.errors.length > 0) { - throw new Errors.ContextualAggregateError( - `One or more errors in the execution result.`, - {}, - // @ts-expect-error fixme - result.errors, - ) - } - return result - } + return (selectionSet) => rootType(isOrThrow, selectionSet) } else { const fieldName = isOrThrow ? key.slice(0, -7) : key - return async (argsOrSelectionSet?: object) => { - const result = await executeRootTypeField(context, rootTypeName, fieldName)(argsOrSelectionSet) // eslint-disable-line - if (isOrThrow && result instanceof Error) throw result - // todo consolidate - // eslint-disable-next-line - if (isOrThrow && returnMode === `graphql` && result.errors.length > 0) { - throw new Errors.ContextualAggregateError( - `One or more errors in the execution result.`, - {}, - // eslint-disable-next-line - result.errors, - ) - } - return result - } + return (argsOrSelectionSet) => rootTypeField(isOrThrow, fieldName, argsOrSelectionSet) } }, }) + } + + const context: Context = { + core: Core.create(), + extensions: state.extensions, + config: { + returnMode, + }, + } + + const run = async (context: Context, initialInput: HookInputEncode) => { + return await Anyware.runWithExtensions({ + core: context.core, + initialInput, + extensions: context.extensions, + }) + } // @ts-expect-error ignoreme const client: Client = { raw: async (input2: RawInput) => { - return await requestOrExecute({ - ...input2, - schema: input.schema, - }) + const interface_: InterfaceRaw = `raw` + const transport = input.schema instanceof GraphQLSchema ? `memory` : `http` + const initialInput: HookInputEncode = { + interface: interface_, + document: input2.document, + context: { + // config: context.config, + transport, + interface: interface_, + }, + transport, + schema: input.schema as string | URL, + } + const result = await run(context, initialInput) + return result }, rawOrThrow: async ( input2: RawInput, ) => { - const result = await requestOrExecute({ - ...input2, - schema: input.schema, - }) + const result = await client.raw(input2) if (result instanceof Error) throw result // todo consolidate if (result.errors && result.errors.length > 0) { @@ -298,52 +319,51 @@ export const createInternal = ( } return result }, - extend: (extension: Extension) => { + extend: (extension) => { + // todo test that adding extensions returns a copy of client return createInternal(input, { extensions: [...state.extensions, extension] }) }, } + // todo extract this into constructor "create typed client" if (input.schemaIndex) { - const schemaIndex = input.schemaIndex - const context: Context = { - schemaIndex, - config: { - returnMode, - }, + const typedContext: TypedContext = { + ...context, + schemaIndex: input.schemaIndex, } Object.assign(client, { document: (documentObject: DocumentObject) => { - const run = async (operationName: string) => { - // 1. if returnMode is successData OR using orThrow - // 2. for each root type key - // 3. filter to only result fields - // 4. inject __typename selection - // if (returnMode === 'successData') { - // Object.values(documentObject).forEach((rootTypeSelection) => { - // Object.entries(rootTypeSelection).forEach(([fieldExpression, fieldValue]) => { - // if (fieldExpression === 'result') { - // // @ts-expect-error fixme - // fieldValue.__typename = true - // } - // }) - // }) - // } - // todo this does not support custom scalars - - const documentString = toDocumentString(context, documentObject) - const result = await requestOrExecute({ - schema: input.schema, - document: documentString, - operationName, - extensions: state.extensions, - // todo variables - }) - return handleReturn(schemaIndex, result, returnMode) - } - return { - run, + run: async (operationName: string) => { + // 1. if returnMode is successData OR using orThrow + // 2. for each root type key + // 3. filter to only result fields + // 4. inject __typename selection + // if (returnMode === 'successData') { + // Object.values(documentObject).forEach((rootTypeSelection) => { + // Object.entries(rootTypeSelection).forEach(([fieldExpression, fieldValue]) => { + // if (fieldExpression === 'result') { + // // @ts-expect-error fixme + // fieldValue.__typename = true + // } + // }) + // }) + // } + // todo this does not support custom scalars + + const documentString = toDocumentString(typedContext, documentObject) + const result = await run(typedContext, { + // todo fix input + schema: input.schema, + document: documentString, + operationName, + extensions: typedContext.extensions, + // todo variables + }) + return handleReturn(typedContext, result) + }, + // todo call into non-throwing version runOrThrow: async (operationName: string) => { const documentString = toDocumentString({ ...context, @@ -352,21 +372,28 @@ export const createInternal = ( returnMode: `successData`, }, }, documentObject) - const result = await requestOrExecute({ + const result = await run(typedContext, { + // todo fix input schema: input.schema, document: documentString, operationName, - extensions: state.extensions, + extensions: typedContext.extensions, // todo variables }) // todo refactor... - const resultReturn = handleReturn(schemaIndex, result, `successData`) + const resultReturn = handleReturn({ + ...typedContext, + config: { + ...typedContext.config, + returnMode: `successData`, + }, + }, result) return returnMode === `graphql` ? result : resultReturn }, } }, - query: createRootTypeMethods(context, `Query`), - mutation: createRootTypeMethods(context, `Mutation`), + query: createRootTypeMethods(typedContext, `Query`), + mutation: createRootTypeMethods(typedContext, `Mutation`), // todo // subscription: async () => {}, }) @@ -378,11 +405,10 @@ export const createInternal = ( type GraffleExecutionResult = ExecutionResult | ErrorAnywareExtensionEntrypoint const handleReturn = ( - schemaIndex: Schema.Index, + context: TypedContext, result: GraffleExecutionResult, - returnMode: ReturnModeType, ) => { - switch (returnMode) { + switch (context.config.returnMode) { case `dataAndErrors`: case `successData`: case `data`: { @@ -392,10 +418,10 @@ const handleReturn = ( {}, result.errors!, )) - if (returnMode === `data` || returnMode === `successData`) throw error + if (context.config.returnMode === `data` || context.config.returnMode === `successData`) throw error return error } - if (returnMode === `successData`) { + if (context.config.returnMode === `successData`) { if (!isPlainObject(result.data)) throw new Error(`Expected data to be an object.`) const schemaErrors = Object.entries(result.data).map(([rootFieldName, rootFieldValue]) => { // todo this check would be nice but it doesn't account for aliases right now. To achieve this we would @@ -409,7 +435,7 @@ const handleReturn = ( const __typename = rootFieldValue[`__typename`] if (typeof __typename !== `string`) throw new Error(`Expected __typename to be selected and a string.`) const isErrorObject = Boolean( - schemaIndex.error.objectsTypename[__typename], + context.schemaIndex.error.objectsTypename[__typename], ) if (!isErrorObject) return null // todo extract message From c3b1c88c9af76b1c2caecb5443ce79e4eb19da34 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 20 May 2024 13:25:24 -0400 Subject: [PATCH 14/30] fix document --- src/layers/5_client/client.batch.test.ts | 24 ++++++ src/layers/5_client/client.document.test.ts | 4 +- src/layers/5_client/client.ts | 83 +++++++-------------- src/layers/5_client/document.ts | 4 +- src/lib/graphql.ts | 2 +- 5 files changed, 58 insertions(+), 59 deletions(-) create mode 100644 src/layers/5_client/client.batch.test.ts diff --git a/src/layers/5_client/client.batch.test.ts b/src/layers/5_client/client.batch.test.ts new file mode 100644 index 00000000..31be0dd7 --- /dev/null +++ b/src/layers/5_client/client.batch.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'vitest' +import { db } from '../../../tests/_/db.js' +import { Graffle } from '../../../tests/_/schema/generated/__.js' +import * as Schema from '../../../tests/_/schema/schema.js' + +const graffle = Graffle.create({ schema: Schema.schema }) + +// dprint-ignore +describe(`query`, () => { + test(`success`, async () => { + await expect(graffle.query.$batch({ id: true })).resolves.toMatchObject({ id:db.id }) + }) + test(`error`, async () => { + await expect(graffle.query.$batch({ error: true })).rejects.toMatchObject(db.errorAggregate) + }) + describe(`orThrow`, () => { + test(`success`, async () => { + await expect(graffle.query.$batchOrThrow({ id: true })).resolves.toMatchObject({ id:db.id }) + }) + test(`error`, async () => { + await expect(graffle.query.$batchOrThrow({ error: true })).rejects.toMatchObject(db.errorAggregate) + }) + }) +}) diff --git a/src/layers/5_client/client.document.test.ts b/src/layers/5_client/client.document.test.ts index 84e2fe03..366bdf5b 100644 --- a/src/layers/5_client/client.document.test.ts +++ b/src/layers/5_client/client.document.test.ts @@ -30,7 +30,7 @@ describe(`document with two queries`, () => { // @ts-expect-error await expect(run(`boo`)).rejects.toMatchObject({ errors: [{ message: `Unknown operation named "boo".` }] }) }) - test(`error if invalid name in document`, async () => { + test.skip(`error if invalid name in document`, async () => { // @ts-expect-error const { run } = graffle.document({ foo$: { query: { id: true } } }) await expect(run(`foo$`)).rejects.toMatchObject({ @@ -86,7 +86,7 @@ describe(`document(...).runOrThrow()`, () => { `[Error: Failure on field resultNonNull: ErrorOne]`, ) }) - test(`multiple via alias`, async () => { + test.todo(`multiple via alias`, async () => { const result = graffle.document({ x: { query: { resultNonNull: { $: { case: `ErrorOne` } }, resultNonNull_as_x: { $: { case: `ErrorOne` } } } }, }).runOrThrow() diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index eca1f122..ff2601f0 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -3,7 +3,7 @@ import { Anyware } from '../../lib/anyware/__.js' import type { ErrorAnywareExtensionEntrypoint } from '../../lib/anyware/getEntrypoint.js' import { Errors } from '../../lib/errors/__.js' import type { SomeExecutionResultWithoutErrors } from '../../lib/graphql.js' -import { type RootTypeName, rootTypeNameToOperationName } from '../../lib/graphql.js' +import { operationTypeNameToRootTypeName, type RootTypeName, rootTypeNameToOperationName } from '../../lib/graphql.js' import { isPlainObject } from '../../lib/prelude.js' import type { SchemaInput } from '../0_functions/requestOrExecute.js' // import { requestOrExecute } from '../0_functions/requestOrExecute.js' @@ -269,6 +269,8 @@ export const createInternal = ( }) } + // todo rename to config + // todo integrate input const context: Context = { core: Core.create(), extensions: state.extensions, @@ -334,61 +336,34 @@ export const createInternal = ( Object.assign(client, { document: (documentObject: DocumentObject) => { + const hasMultipleOperations = Object.keys(documentObject).length > 1 + const processInput = (maybeOperationName: string) => { + if (!maybeOperationName && hasMultipleOperations) { + throw { + errors: [new Error(`Must provide operation name if query contains multiple operations.`)], + } + } + if (maybeOperationName && !(maybeOperationName in documentObject)) { + throw { + errors: [new Error(`Unknown operation named "${maybeOperationName}".`)], + } + } + const operationName = maybeOperationName ? maybeOperationName : Object.keys(documentObject)[0]! + const operationTypeName = Object.keys(documentObject[operationName])[0] + const selection = documentObject[operationName][operationTypeName] + return { + operationTypeName, + selection, + } + } return { - run: async (operationName: string) => { - // 1. if returnMode is successData OR using orThrow - // 2. for each root type key - // 3. filter to only result fields - // 4. inject __typename selection - // if (returnMode === 'successData') { - // Object.values(documentObject).forEach((rootTypeSelection) => { - // Object.entries(rootTypeSelection).forEach(([fieldExpression, fieldValue]) => { - // if (fieldExpression === 'result') { - // // @ts-expect-error fixme - // fieldValue.__typename = true - // } - // }) - // }) - // } - // todo this does not support custom scalars - - const documentString = toDocumentString(typedContext, documentObject) - const result = await run(typedContext, { - // todo fix input - schema: input.schema, - document: documentString, - operationName, - extensions: typedContext.extensions, - // todo variables - }) - return handleReturn(typedContext, result) + run: async (maybeOperationName: string) => { + const { selection, operationTypeName } = processInput(maybeOperationName) + return await client[operationTypeName].$batch(selection) }, - // todo call into non-throwing version - runOrThrow: async (operationName: string) => { - const documentString = toDocumentString({ - ...context, - config: { - ...context.config, - returnMode: `successData`, - }, - }, documentObject) - const result = await run(typedContext, { - // todo fix input - schema: input.schema, - document: documentString, - operationName, - extensions: typedContext.extensions, - // todo variables - }) - // todo refactor... - const resultReturn = handleReturn({ - ...typedContext, - config: { - ...typedContext.config, - returnMode: `successData`, - }, - }, result) - return returnMode === `graphql` ? result : resultReturn + runOrThrow: async (maybeOperationName: string) => { + const { selection, operationTypeName } = processInput(maybeOperationName) + return await client[operationTypeName].$batchOrThrow(selection) }, } }, diff --git a/src/layers/5_client/document.ts b/src/layers/5_client/document.ts index fc55b380..33d27829 100644 --- a/src/layers/5_client/document.ts +++ b/src/layers/5_client/document.ts @@ -1,5 +1,5 @@ import type { MergeExclusive, NonEmptyObject } from 'type-fest' -import { operationTypeToRootType } from '../../lib/graphql.js' +import { operationTypeNameToRootTypeName } from '../../lib/graphql.js' import type { IsMultipleKeys } from '../../lib/prelude.js' import type { TSError } from '../../lib/TSError.js' import type { Schema } from '../1_Schema/__.js' @@ -32,7 +32,7 @@ export const toDocumentString = ( ) => { return Object.entries(document).map(([operationName, operationDocument]) => { const operationType = `query` in operationDocument ? `query` : `mutation` - const rootType = operationTypeToRootType[operationType] + const rootType = operationTypeNameToRootTypeName[operationType] const rootTypeDocument = (operationDocument as any)[operationType] as SelectionSet.Print.GraphQLObjectSelection // eslint-disable-line const schemaRootType = context.schemaIndex[`Root`][rootType] diff --git a/src/lib/graphql.ts b/src/lib/graphql.ts index 66759e24..74b7f459 100644 --- a/src/lib/graphql.ts +++ b/src/lib/graphql.ts @@ -36,7 +36,7 @@ export const RootTypeName = { Subscription: `Subscription`, } as const -export const operationTypeToRootType = { +export const operationTypeNameToRootTypeName = { query: `Query`, mutation: `Mutation`, subscription: `Subscription`, From c16f91e4285df42fb684ba42f92d49369803ffca Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 20 May 2024 17:13:03 -0400 Subject: [PATCH 15/30] add destructure anyware tests --- src/layers/5_client/client.returnMode.test.ts | 2 +- src/lib/anyware/getEntrypoint.ts | 2 +- src/lib/anyware/main.entrypoint.spec.ts | 85 ++++++++++++++ src/lib/anyware/main.spec.ts | 104 +----------------- src/lib/anyware/main.ts | 19 +++- src/lib/anyware/specHelpers.ts | 35 ++++++ src/lib/errors/ContextualAggregateError.ts | 12 ++ src/lib/prelude.ts | 17 +++ 8 files changed, 167 insertions(+), 109 deletions(-) create mode 100644 src/lib/anyware/main.entrypoint.spec.ts create mode 100644 src/lib/anyware/specHelpers.ts diff --git a/src/layers/5_client/client.returnMode.test.ts b/src/layers/5_client/client.returnMode.test.ts index 125afa73..e5aaecac 100644 --- a/src/layers/5_client/client.returnMode.test.ts +++ b/src/layers/5_client/client.returnMode.test.ts @@ -61,7 +61,7 @@ describe('dataAndErrors', () => { test('query.', async () => { await expect(graffle.query.__typename()).resolves.toEqual('Query') }) - test('query. error', async () => { + test.only('query. error', async () => { await expect(graffle.query.error()).resolves.toMatchObject(db.errorAggregate) }) test('query. error orThrow', async () => { diff --git a/src/lib/anyware/getEntrypoint.ts b/src/lib/anyware/getEntrypoint.ts index 81d6faff..38ddea54 100644 --- a/src/lib/anyware/getEntrypoint.ts +++ b/src/lib/anyware/getEntrypoint.ts @@ -9,7 +9,7 @@ export class ErrorAnywareExtensionEntrypoint extends ContextualError< > { // todo add to context: parameters value parsed and raw constructor(context: { issue: ExtensionEntryHookIssue }) { - super(`Extension must destructure the input object and select an entry hook to use.`, context) + super(`Extension must destructure the first parameter passed to it and select exactly one entrypoint.`, context) } } diff --git a/src/lib/anyware/main.entrypoint.spec.ts b/src/lib/anyware/main.entrypoint.spec.ts new file mode 100644 index 00000000..c695af63 --- /dev/null +++ b/src/lib/anyware/main.entrypoint.spec.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'vitest' +import { run } from './specHelpers.js' + +describe(`invalid destructuring cases`, () => { + test(`noParameters`, async () => { + const result = await run(() => 1) + expect(result).toMatchInlineSnapshot( + `[ContextualAggregateError: One or more extensions are invalid.]`, + ) + expect(result.errors).toMatchInlineSnapshot(` + [ + [ContextualError: Extension must destructure the first parameter passed to it and select exactly one entrypoint.], + ] + `) + expect(result.errors[0].context).toMatchInlineSnapshot(` + { + "issue": "noParameters", + } + `) + }) + test(`destructuredWithoutEntryHook`, async () => { + const result = await run(async ({ request2 }) => {}) + expect(result).toMatchInlineSnapshot( + `[ContextualAggregateError: One or more extensions are invalid.]`, + ) + expect(result.errors).toMatchInlineSnapshot(` + [ + [ContextualError: Extension must destructure the first parameter passed to it and select exactly one entrypoint.], + ] + `) + expect(result.errors[0].context).toMatchInlineSnapshot(` + { + "issue": "destructuredWithoutEntryHook", + } + `) + }) + test(`multipleParameters`, async () => { + const result = await run(async ({ request }, two) => {}) + expect(result).toMatchInlineSnapshot( + `[ContextualAggregateError: One or more extensions are invalid.]`, + ) + expect(result.errors).toMatchInlineSnapshot(` + [ + [ContextualError: Extension must destructure the first parameter passed to it and select exactly one entrypoint.], + ] + `) + expect(result.errors[0].context).toMatchInlineSnapshot(` + { + "issue": "multipleParameters", + } + `) + }) + test(`notDestructured`, async () => { + const result = await run(async (_) => {}) + expect(result).toMatchInlineSnapshot( + `[ContextualAggregateError: One or more extensions are invalid.]`, + ) + expect(result.errors).toMatchInlineSnapshot(` + [ + [ContextualError: Extension must destructure the first parameter passed to it and select exactly one entrypoint.], + ] + `) + expect(result.errors[0].context).toMatchInlineSnapshot(` + { + "issue": "notDestructured", + } + `) + }) + test(`multipleDestructuredHookNames`, async () => { + const result = await run(async ({ a, b }) => {}) + expect(result).toMatchInlineSnapshot( + `[ContextualAggregateError: One or more extensions are invalid.]`, + ) + expect(result.errors).toMatchInlineSnapshot(` + [ + [ContextualError: Extension must destructure the first parameter passed to it and select exactly one entrypoint.], + ] + `) + expect(result.errors[0].context).toMatchInlineSnapshot(` + { + "issue": "multipleDestructuredHookNames", + } + `) + }) +}) diff --git a/src/lib/anyware/main.spec.ts b/src/lib/anyware/main.spec.ts index e40b124e..e1237113 100644 --- a/src/lib/anyware/main.spec.ts +++ b/src/lib/anyware/main.spec.ts @@ -1,107 +1,5 @@ -import type { Mock } from 'vitest' import { describe, expect, test, vi } from 'vitest' -import type { Core, ExtensionInput, Options } from './main.js' -import { runWithExtensions } from './main.js' - -type Input = { value: string } - -type $Core = Core<'a' | 'b', { a: Mock; b: Mock }> - -const createCore = (): $Core => { - const a = vi.fn().mockImplementation((input: Input) => { - return { value: input.value + `+a` } - }) - const b = vi.fn().mockImplementation((input: Input) => { - return { value: input.value + `+b` } - }) - - return { - hookNamesOrderedBySequence: [`a`, `b`], - hooks: { a, b }, - } -} - -const runWithOptions = (options: Options = {}) => async (...extensions: ExtensionInput[]) => { - core = createCore() - - const result = await runWithExtensions({ - core, - initialInput: { value: `initial` }, - extensions, - options, - }) - return result -} - -const run = async (...extensions: ExtensionInput[]) => runWithOptions({})(...extensions) - -let core: $Core - -// todo -// describe('invalid destructuring cases', () => { -// const make = async (extension: Extension) => -// (await client.extend(extension).query.id()) as any as ErrorAnywareExtensionEntrypoint - -// test('noParameters', async () => { -// const result = await make(async ({}) => {}) -// expect(result).toMatchInlineSnapshot( -// `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, -// ) -// expect(result.context).toMatchInlineSnapshot(` -// { -// "issue": "noParameters", -// } -// `) -// }) -// test('noParameters', async () => { -// const result = await make(async () => {}) -// expect(result).toMatchInlineSnapshot( -// `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, -// ) -// expect(result.context).toMatchInlineSnapshot(` -// { -// "issue": "noParameters", -// } -// `) -// }) -// test('destructuredWithoutEntryHook', async () => { -// // @ts-expect-error -// const result = await make(async ({ request2 }) => {}) -// expect(result).toMatchInlineSnapshot( -// `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, -// ) -// expect(result.context).toMatchInlineSnapshot(` -// { -// "issue": "destructuredWithoutEntryHook", -// } -// `) -// }) -// test('multipleParameters', async () => { -// // @ts-expect-error -// const result = await make(async ({ request }, two) => {}) -// expect(result).toMatchInlineSnapshot( -// `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, -// ) -// expect(result.context).toMatchInlineSnapshot(` -// { -// "issue": "multipleParameters", -// } -// `) -// }) -// test('notDestructured', async () => { -// const result = await make(async (_) => {}) -// expect(result).toMatchInlineSnapshot( -// `[ContextualError: Extension must destructure the input object and select an entry hook to use.]`, -// ) -// expect(result.context).toMatchInlineSnapshot(` -// { -// "issue": "notDestructured", -// } -// `) -// }) -// // todo once we have multiple hooks test this case: -// // multipleDestructuredHookNames -// }) +import { core, run, runWithOptions } from './specHelpers.js' describe(`no extensions`, () => { test(`passthrough to implementation`, async () => { diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts index 1429dbb3..591e46fb 100644 --- a/src/lib/anyware/main.ts +++ b/src/lib/anyware/main.ts @@ -4,6 +4,8 @@ // E.g.: NOT await request(request.input) // but instead simply: await request() +import { Errors } from '../errors/__.js' +import { partitionAndAggregateErrors } from '../errors/ContextualAggregateError.js' import { ContextualError } from '../errors/ContextualError.js' import type { Deferred, @@ -13,7 +15,8 @@ import type { SomeAsyncFunction, SomeMaybeAsyncFunction, } from '../prelude.js' -import { casesExhausted, createDeferred, debug, errorFromMaybeError } from '../prelude.js' +import { casesExhausted, createDeferred, debug, errorFromMaybeError, partitionErrors } from '../prelude.js' +import type { ErrorAnywareExtensionEntrypoint } from './getEntrypoint.js' import { getEntrypoint } from './getEntrypoint.js' type HookSequence = readonly [string, ...string[]] @@ -316,8 +319,7 @@ const toInternalExtension = (core: Core, config: Config, extension: SomeAsyncFun const entrypoint = getEntrypoint(core.hookNamesOrderedBySequence, extension) if (entrypoint instanceof Error) { if (config.entrypointSelectionMode === `required`) { - // todo return error and make part of types - throw entrypoint + return entrypoint } else { currentChunk.promise.then(appplyBody) return { @@ -376,9 +378,18 @@ export const runWithExtensions = async <$Core extends Core>( options?: Options }, ) => { + const initialHookStackAndErrors = extensions.map(extension => + toInternalExtension(core, resolveOptions(options), extension) + ) + const [initialHookStack, error] = partitionAndAggregateErrors(initialHookStackAndErrors) + + if (error) { + return error + } + return await run({ core, initialInput, - initialHookStack: extensions.map(extension => toInternalExtension(core, resolveOptions(options), extension)), + initialHookStack, }) } diff --git a/src/lib/anyware/specHelpers.ts b/src/lib/anyware/specHelpers.ts new file mode 100644 index 00000000..f9a23351 --- /dev/null +++ b/src/lib/anyware/specHelpers.ts @@ -0,0 +1,35 @@ +import { vi } from 'vitest' +import { type Core, type ExtensionInput, type Options, runWithExtensions } from './main.js' + +export type Input = { value: string } + +export type $Core = Core + +export let core: $Core = null + +export const createCore = (): $Core => { + const a = vi.fn().mockImplementation((input: Input) => { + return { value: input.value + `+a` } + }) + const b = vi.fn().mockImplementation((input: Input) => { + return { value: input.value + `+b` } + }) + + return { + hookNamesOrderedBySequence: [`a`, `b`], + hooks: { a, b }, + } +} + +export const runWithOptions = (options: Options = {}) => async (...extensions: ExtensionInput[]) => { + core = createCore() + const result = await runWithExtensions({ + core, + initialInput: { value: `initial` }, + extensions, + options, + }) + return result +} + +export const run = async (...extensions: ExtensionInput[]) => runWithOptions({})(...extensions) diff --git a/src/lib/errors/ContextualAggregateError.ts b/src/lib/errors/ContextualAggregateError.ts index e450727a..43bb0778 100644 --- a/src/lib/errors/ContextualAggregateError.ts +++ b/src/lib/errors/ContextualAggregateError.ts @@ -1,3 +1,5 @@ +import type { Include } from '../prelude.js' +import { partitionErrors } from '../prelude.js' import { ContextualError } from './ContextualError.js' import type { Cause } from './types.js' @@ -24,3 +26,13 @@ export class ContextualAggregateError< super(message, context, undefined) } } + +export const partitionAndAggregateErrors = ( + results: Results[], +): [Exclude[], null | ContextualAggregateError>] => { + const [values, errors] = partitionErrors(results) + const error = errors.length > 0 + ? new ContextualAggregateError(`One or more extensions are invalid.`, {}, errors) + : null + return [values, error] +} diff --git a/src/lib/prelude.ts b/src/lib/prelude.ts index b111870c..0b8dd34d 100644 --- a/src/lib/prelude.ts +++ b/src/lib/prelude.ts @@ -1,5 +1,7 @@ import { resolve } from 'dns' import type { ConditionalSimplifyDeep } from 'type-fest/source/conditional-simplify.js' +import { Errors } from './errors/__.js' +import type { ContextualAggregateError } from './errors/ContextualAggregateError.js' /* eslint-disable */ export type RemoveIndex = { @@ -310,3 +312,18 @@ export type FindValueAfterOr = 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[]] => { + const errors: Include[] = [] + const values: Exclude[] = [] + for (const item of array) { + if (item instanceof Error) { + errors.push(item as any) + } else { + values.push(item as any) + } + } + return [values, errors] +} From ea9ac4e2325768c807f6d01a42f3cd9842ac69d8 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 20 May 2024 21:03:40 -0400 Subject: [PATCH 16/30] better error handling --- src/lib/anyware/main.entrypoint.spec.ts | 137 +++++++++++++----------- src/lib/anyware/main.spec.ts | 67 +++++++++++- src/lib/anyware/main.ts | 106 ++++++++++++------ src/lib/anyware/specHelpers.ts | 18 +++- src/lib/prelude.ts | 2 +- 5 files changed, 227 insertions(+), 103 deletions(-) diff --git a/src/lib/anyware/main.entrypoint.spec.ts b/src/lib/anyware/main.entrypoint.spec.ts index c695af63..0aa8f784 100644 --- a/src/lib/anyware/main.entrypoint.spec.ts +++ b/src/lib/anyware/main.entrypoint.spec.ts @@ -1,84 +1,99 @@ import { describe, expect, test } from 'vitest' +import type { ContextualAggregateError } from '../errors/ContextualAggregateError.js' import { run } from './specHelpers.js' describe(`invalid destructuring cases`, () => { test(`noParameters`, async () => { - const result = await run(() => 1) - expect(result).toMatchInlineSnapshot( - `[ContextualAggregateError: One or more extensions are invalid.]`, - ) - expect(result.errors).toMatchInlineSnapshot(` - [ - [ContextualError: Extension must destructure the first parameter passed to it and select exactly one entrypoint.], - ] - `) - expect(result.errors[0].context).toMatchInlineSnapshot(` + const result = await run(() => 1) as ContextualAggregateError + expect({ + result, + errors: result.errors, + context: result.errors[0]?.context, + }).toMatchInlineSnapshot(` { - "issue": "noParameters", + "context": { + "issue": "noParameters", + }, + "errors": [ + [ContextualError: Extension must destructure the first parameter passed to it and select exactly one entrypoint.], + ], + "result": [ContextualAggregateError: One or more extensions are invalid.], } `) }) test(`destructuredWithoutEntryHook`, async () => { - const result = await run(async ({ request2 }) => {}) - expect(result).toMatchInlineSnapshot( - `[ContextualAggregateError: One or more extensions are invalid.]`, - ) - expect(result.errors).toMatchInlineSnapshot(` - [ - [ContextualError: Extension must destructure the first parameter passed to it and select exactly one entrypoint.], - ] - `) - expect(result.errors[0].context).toMatchInlineSnapshot(` - { + const result = await run(async ({ x }) => {}) as ContextualAggregateError + expect({ + result, + errors: result.errors, + context: result.errors[0]?.context, + }).toMatchInlineSnapshot( + ` + { + "context": { "issue": "destructuredWithoutEntryHook", - } - `) + }, + "errors": [ + [ContextualError: Extension must destructure the first parameter passed to it and select exactly one entrypoint.], + ], + "result": [ContextualAggregateError: One or more extensions are invalid.], + } + `, + ) }) test(`multipleParameters`, async () => { - const result = await run(async ({ request }, two) => {}) - expect(result).toMatchInlineSnapshot( - `[ContextualAggregateError: One or more extensions are invalid.]`, - ) - expect(result.errors).toMatchInlineSnapshot(` - [ - [ContextualError: Extension must destructure the first parameter passed to it and select exactly one entrypoint.], - ] - `) - expect(result.errors[0].context).toMatchInlineSnapshot(` - { + const result = await run(async ({ x }, y) => {}) as ContextualAggregateError + expect({ + result, + errors: result.errors, + context: result.errors[0]?.context, + }).toMatchInlineSnapshot( + ` + { + "context": { "issue": "multipleParameters", - } - `) + }, + "errors": [ + [ContextualError: Extension must destructure the first parameter passed to it and select exactly one entrypoint.], + ], + "result": [ContextualAggregateError: One or more extensions are invalid.], + } + `, + ) }) test(`notDestructured`, async () => { - const result = await run(async (_) => {}) - expect(result).toMatchInlineSnapshot( - `[ContextualAggregateError: One or more extensions are invalid.]`, - ) - expect(result.errors).toMatchInlineSnapshot(` - [ - [ContextualError: Extension must destructure the first parameter passed to it and select exactly one entrypoint.], - ] - `) - expect(result.errors[0].context).toMatchInlineSnapshot(` - { + const result = await run(async (_) => {}) as ContextualAggregateError + expect({ + result, + errors: result.errors, + context: result.errors[0]?.context, + }).toMatchInlineSnapshot(` + { + "context": { "issue": "notDestructured", - } - `) + }, + "errors": [ + [ContextualError: Extension must destructure the first parameter passed to it and select exactly one entrypoint.], + ], + "result": [ContextualAggregateError: One or more extensions are invalid.], + } + `) }) test(`multipleDestructuredHookNames`, async () => { - const result = await run(async ({ a, b }) => {}) - expect(result).toMatchInlineSnapshot( - `[ContextualAggregateError: One or more extensions are invalid.]`, - ) - expect(result.errors).toMatchInlineSnapshot(` - [ - [ContextualError: Extension must destructure the first parameter passed to it and select exactly one entrypoint.], - ] - `) - expect(result.errors[0].context).toMatchInlineSnapshot(` + const result = await run(async ({ a, b }) => {}) as ContextualAggregateError + expect({ + result, + errors: result.errors, + context: result.errors[0]?.context, + }).toMatchInlineSnapshot(` { - "issue": "multipleDestructuredHookNames", + "context": { + "issue": "multipleDestructuredHookNames", + }, + "errors": [ + [ContextualError: Extension must destructure the first parameter passed to it and select exactly one entrypoint.], + ], + "result": [ContextualAggregateError: One or more extensions are invalid.], } `) }) diff --git a/src/lib/anyware/main.spec.ts b/src/lib/anyware/main.spec.ts index e1237113..7ad95aea 100644 --- a/src/lib/anyware/main.spec.ts +++ b/src/lib/anyware/main.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, test, vi } from 'vitest' -import { core, run, runWithOptions } from './specHelpers.js' +import type { ContextualError } from '../errors/ContextualError.js' +import { core, oops, run, runWithOptions } from './specHelpers.js' describe(`no extensions`, () => { test(`passthrough to implementation`, async () => { @@ -148,6 +149,64 @@ describe(`two extensions`, () => { }) }) -// todo some tests regarding error handling -// extension throws an error -// implementation throws an error +describe(`errors`, () => { + test(`extension that throws a non-error is wrapped in error`, async () => { + const result = await run(async ({ a }) => { + throw `oops` + }) as ContextualError + expect({ + result, + context: result.context, + cause: result.cause, + }).toMatchInlineSnapshot(` + { + "cause": [Error: oops], + "context": { + "extensionName": "anonymous", + "hookName": "a", + "source": "extension", + }, + "result": [ContextualError: There was an error in the extension "anonymous" (use named functions to improve this error message) while running hook "a".], + } + `) + }) + test(`extension throws asynchronously`, async () => { + const result = await run(async ({ a }) => { + throw oops + }) as ContextualError + expect({ + result, + context: result.context, + cause: result.cause, + }).toMatchInlineSnapshot(` + { + "cause": [Error: oops], + "context": { + "extensionName": "anonymous", + "hookName": "a", + "source": "extension", + }, + "result": [ContextualError: There was an error in the extension "anonymous" (use named functions to improve this error message) while running hook "a".], + } + `) + }) + + test(`implementation throws`, async () => { + core.hooks.a.mockReset().mockRejectedValueOnce(oops) + const result = await run() as ContextualError + expect({ + result, + context: result.context, + cause: result.cause, + }).toMatchInlineSnapshot(` + { + "cause": [Error: oops], + "context": { + "hookName": "a", + "source": "implementation", + }, + "result": [ContextualError: There was an error in the core implementation of hook "a".], + } + `) + }) +}) diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts index 591e46fb..017de3ef 100644 --- a/src/lib/anyware/main.ts +++ b/src/lib/anyware/main.ts @@ -4,7 +4,6 @@ // E.g.: NOT await request(request.input) // but instead simply: await request() -import { Errors } from '../errors/__.js' import { partitionAndAggregateErrors } from '../errors/ContextualAggregateError.js' import { ContextualError } from '../errors/ContextualError.js' import type { @@ -15,8 +14,7 @@ import type { SomeAsyncFunction, SomeMaybeAsyncFunction, } from '../prelude.js' -import { casesExhausted, createDeferred, debug, errorFromMaybeError, partitionErrors } from '../prelude.js' -import type { ErrorAnywareExtensionEntrypoint } from './getEntrypoint.js' +import { casesExhausted, createDeferred, debug, errorFromMaybeError } from '../prelude.js' import { getEntrypoint } from './getEntrypoint.js' type HookSequence = readonly [string, ...string[]] @@ -129,7 +127,8 @@ export type ExtensionInput = SomeMaybeAsyncFunction type HookDoneData = | { type: 'completed'; result: unknown; nextHookStack: Extension[] } | { type: 'shortCircuited'; result: unknown } - | { type: 'error'; error: Error; source: 'implementation'; hookName: string } + | { type: 'error'; hookName: string; source: 'implementation'; error: Error } + | { type: 'error'; hookName: string; source: 'extension'; error: Error; extensionName: string } type HookDoneResolver = (input: HookDoneData) => void @@ -189,29 +188,49 @@ const runHook = async <$HookName extends string>( const { branch, result } = await Promise.race([ hookUsedDeferred.promise.then(result => { return { branch: `hook`, result } as const - }), + }).catch((e: unknown) => ({ branch: `hookError`, result: e } as const)), pausedExtension.body.promise.then(result => { return { branch: `body`, result } as const - }), + }).catch((e: unknown) => ({ branch: `bodyError`, result: e } as const)), ]) debug(`${name}: ${pausedExtension.name}: branch`, branch) - if (branch === `body`) { - if (result === envelope) { - runHook({ - core, - name, - done, - originalInput, - currentHookStack: nextCurrentHookStack, - nextHookStack, + switch (branch) { + case `body`: { + if (result === envelope) { + runHook({ + core, + name, + done, + originalInput, + currentHookStack: nextCurrentHookStack, + nextHookStack, + }) + } else { + done({ type: `shortCircuited`, result }) + } + return + } + case `bodyError`: { + done({ + type: `error`, + hookName: name, + source: `extension`, + error: errorFromMaybeError(result), + extensionName: pausedExtension.name, }) - } else { - done({ type: `shortCircuited`, result }) + return } + case `hookError`: + done({ type: `error`, hookName: name, source: `implementation`, error: errorFromMaybeError(result) }) + return + case `hook`: { + // do nothing, hook is making the processing continue. + return + } + default: + throw casesExhausted(branch) } - - return } // Reached bottom of the stack @@ -224,7 +243,7 @@ const runHook = async <$HookName extends string>( try { result = await implementation(originalInput) } catch (error) { - done({ type: `error`, error: errorFromMaybeError(error), source: `implementation`, hookName: name }) + done({ type: `error`, hookName: name, source: `implementation`, error: errorFromMaybeError(error) }) return } @@ -268,10 +287,23 @@ const run = async ( } case `error`: { debug(`signal: error`) - // todo constructor error lower in stack for better trace? - // todo return? - const message = `There was an error in the core implementation of hook "${signal.hookName}".` - throw new ContextualError(message, { hookName: signal.hookName }, signal.error) + // todo type test for this possible return value + switch (signal.source) { + case `extension`: { + const nameTip = signal.extensionName ? ` (use named functions to improve this error message)` : `` + const message = + `There was an error in the extension "${signal.extensionName}"${nameTip} while running hook "${signal.hookName}".` + return new ContextualError(message, { + hookName: signal.hookName, + source: signal.source, + extensionName: signal.extensionName, + }, signal.error) + } + case `implementation`: { + const message = `There was an error in the core implementation of hook "${signal.hookName}".` + return new ContextualError(message, { hookName: signal.hookName, source: signal.source }, signal.error) + } + } } default: casesExhausted(signal) @@ -299,16 +331,22 @@ const createPassthrough = (hookName: string) => async (hookEnvelope) => { const toInternalExtension = (core: Core, config: Config, extension: SomeAsyncFunction) => { const currentChunk = createDeferred() const body = createDeferred() - const appplyBody = async (input) => { - const result = await extension(input) - body.resolve(result) + const applyBody = async (input) => { + try { + const result = await extension(input) + body.resolve(result) + } catch (error) { + body.reject(error) + } } + const extensionName = extension.name || `anonymous` + switch (config.entrypointSelectionMode) { case `off`: { - currentChunk.promise.then(appplyBody) + currentChunk.promise.then(applyBody) return { - name: extension.name, + name: extensionName, entrypoint: core.hookNamesOrderedBySequence[0], // todo non-empty-array datastructure body, currentChunk, @@ -321,10 +359,10 @@ const toInternalExtension = (core: Core, config: Config, extension: SomeAsyncFun if (config.entrypointSelectionMode === `required`) { return entrypoint } else { - currentChunk.promise.then(appplyBody) + currentChunk.promise.then(applyBody) return { - name: extension.name, - entrypoint: core.hookNamesOrderedBySequence[0], // todo non-empty-array datastructure + name: extensionName, + entrypoint: core.hookNamesOrderedBySequence[0], // todo non-empty-array data structure body, currentChunk, } @@ -342,10 +380,10 @@ const toInternalExtension = (core: Core, config: Config, extension: SomeAsyncFun for (const passthrough of passthroughs) { currentChunkPromiseChain = currentChunkPromiseChain.then(passthrough) } - currentChunkPromiseChain.then(appplyBody) + currentChunkPromiseChain.then(applyBody) return { - name: extension.name, + name: extensionName, entrypoint, body, currentChunk, diff --git a/src/lib/anyware/specHelpers.ts b/src/lib/anyware/specHelpers.ts index f9a23351..f2e0f60b 100644 --- a/src/lib/anyware/specHelpers.ts +++ b/src/lib/anyware/specHelpers.ts @@ -1,10 +1,17 @@ -import { vi } from 'vitest' +import type { Mock } from 'vitest' +import { beforeEach, vi } from 'vitest' import { type Core, type ExtensionInput, type Options, runWithExtensions } from './main.js' export type Input = { value: string } -export type $Core = Core +export type $Core = Core<['a', 'b']> & { + hooks: { + a: Mock + b: Mock + } +} +// @ts-expect-error export let core: $Core = null export const createCore = (): $Core => { @@ -21,8 +28,11 @@ export const createCore = (): $Core => { } } -export const runWithOptions = (options: Options = {}) => async (...extensions: ExtensionInput[]) => { +beforeEach(() => { core = createCore() +}) + +export const runWithOptions = (options: Options = {}) => async (...extensions: ExtensionInput[]) => { const result = await runWithExtensions({ core, initialInput: { value: `initial` }, @@ -33,3 +43,5 @@ export const runWithOptions = (options: Options = {}) => async (...extensions: E } export const run = async (...extensions: ExtensionInput[]) => runWithOptions({})(...extensions) + +export const oops = new Error(`oops`) diff --git a/src/lib/prelude.ts b/src/lib/prelude.ts index 0b8dd34d..6a570a71 100644 --- a/src/lib/prelude.ts +++ b/src/lib/prelude.ts @@ -249,7 +249,7 @@ export type SomeMaybeAsyncFunction = (...args: any[]) => MaybePromise export type Deferred = { promise: Promise resolve: (value: T) => void - reject: (error: Error) => void + reject: (error: unknown) => void } export const createDeferred = <$T>(): Deferred<$T> => { let resolve From 4900880340959a9668cc25f2805d04f8c392a76b Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 20 May 2024 22:20:07 -0400 Subject: [PATCH 17/30] fixing things --- DOCUMENTATION_NEXT.md | 30 ++- src/layers/5_client/Config.ts | 10 +- src/layers/5_client/client.returnMode.test.ts | 2 +- .../5_client/client.rootTypeMethods.test.ts | 2 +- src/layers/5_client/client.ts | 173 +++++++++--------- 5 files changed, 120 insertions(+), 97 deletions(-) diff --git a/DOCUMENTATION_NEXT.md b/DOCUMENTATION_NEXT.md index d3ddf96b..ee9c2e7d 100644 --- a/DOCUMENTATION_NEXT.md +++ b/DOCUMENTATION_NEXT.md @@ -16,25 +16,41 @@ You can change the output of client methods by configuring its return mode. This The only client method that is not affected by return mode is `raw` which will _always_ return a standard GraphQL result type. +Here is a summary table of the modes: + +| Mode | Throw Sources (no type safety) | Returns (type safe) | +| ---------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `graphql` | Extensions, Fetch | `GraphQLExecutionResult` | +| `graphqlSuccess` | Extensions, Fetch, GraphQLExecutionResult.errors | `GraphQLExecutionResult` with `.errors` always missing. | +| `data` (default) | Extensions, Fetch, GraphQLExecutionResult.errors | `GraphQLExecutionResult.data` | +| `dataSuccess` | Extensions, Fetch, GraphQLExecutionResult.errors, GraphQLExecutionResult.data Schema Errors | `GraphQLExecutionResult.data` without any schema errors | +| `dataAndErrors` | | `GraphQLExecutionResult.data`, errors from: Extensions, Fetch, GraphQLExecutionResult.errors | + ## `graphql` Return the standard graphql execution output. -## `data` - -Return just the data including [schema errors](#schema-errors) (if using). Other errors are thrown as an `AggregateError`. +## `graphqlSuccess` -**This mode is the default.** +Return the standard graphql execution output. However, if there would be any errors then they're thrown as an `AggregateError`. +This mode acts like you were using [`OrThrow`](#orthrow) method variants all the time. -## `successData` +## `dataSuccess` -Return just the data excluding [schema errors](#schema-errors). Errors are thrown as an `AggregateError`. This mode acts like you were using [`OrThrow`](#orthrow) method variants all the time. +Return just the data excluding [schema errors](#schema-errors). Errors are thrown as an `AggregateError`. +This mode acts like you were using [`OrThrow`](#orthrow) method variants all the time. This mode is only available when using [schema errors](#schema-errors). +## `data` + +Return just the data including [schema errors](#schema-errors) (if using). Other errors are thrown as an `AggregateError`. + +**This mode is the default.** + ## `dataAndErrors` -Return data and errors. This is the most type-safe mode. It never throws. +Return a union type of data and errors. This is the most type-safe mode. It never throws. # Schema Errors diff --git a/src/layers/5_client/Config.ts b/src/layers/5_client/Config.ts index 93f0b5f8..bf0e0088 100644 --- a/src/layers/5_client/Config.ts +++ b/src/layers/5_client/Config.ts @@ -7,11 +7,18 @@ import type { SelectionSet } from '../3_SelectionSet/__.js' export type ReturnModeType = | ReturnModeTypeGraphQL + | ReturnModeTypeGraphQLSuccess | ReturnModeTypeSuccessData | ReturnModeTypeData | ReturnModeTypeDataAndErrors -export type ReturnModeTypeBase = ReturnModeTypeGraphQL | ReturnModeTypeDataAndErrors | ReturnModeTypeData +export type ReturnModeTypeBase = + | ReturnModeTypeGraphQLSuccess + | ReturnModeTypeGraphQL + | ReturnModeTypeDataAndErrors + | ReturnModeTypeData + +export type ReturnModeTypeGraphQLSuccess = 'graphqlSuccess' export type ReturnModeTypeGraphQL = 'graphql' @@ -19,6 +26,7 @@ export type ReturnModeTypeData = 'data' export type ReturnModeTypeDataAndErrors = 'dataAndErrors' +// todo rename to dataSuccess export type ReturnModeTypeSuccessData = 'successData' export type OptionsInput = { diff --git a/src/layers/5_client/client.returnMode.test.ts b/src/layers/5_client/client.returnMode.test.ts index e5aaecac..125afa73 100644 --- a/src/layers/5_client/client.returnMode.test.ts +++ b/src/layers/5_client/client.returnMode.test.ts @@ -61,7 +61,7 @@ describe('dataAndErrors', () => { test('query.', async () => { await expect(graffle.query.__typename()).resolves.toEqual('Query') }) - test.only('query. error', async () => { + test('query. error', async () => { await expect(graffle.query.error()).resolves.toMatchObject(db.errorAggregate) }) test('query. error orThrow', async () => { diff --git a/src/layers/5_client/client.rootTypeMethods.test.ts b/src/layers/5_client/client.rootTypeMethods.test.ts index b6d2b099..80800a15 100644 --- a/src/layers/5_client/client.rootTypeMethods.test.ts +++ b/src/layers/5_client/client.rootTypeMethods.test.ts @@ -37,7 +37,7 @@ describe(`query`, () => { await expect(graffle.query.interface({ onObject2ImplementingInterface: { boolean: true } })).resolves.toEqual({}) }) describe(`orThrow`, () => { - test(`without error`, async () => { + test.only(`without error`, async () => { await expect(graffle.query.objectWithArgsOrThrow({ $: { id: `x` }, id: true })).resolves.toEqual({ id: `x` }) }) test(`with error`, async () => { diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index ff2601f0..3f45db07 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -3,7 +3,7 @@ import { Anyware } from '../../lib/anyware/__.js' import type { ErrorAnywareExtensionEntrypoint } from '../../lib/anyware/getEntrypoint.js' import { Errors } from '../../lib/errors/__.js' import type { SomeExecutionResultWithoutErrors } from '../../lib/graphql.js' -import { operationTypeNameToRootTypeName, type RootTypeName, rootTypeNameToOperationName } from '../../lib/graphql.js' +import { type RootTypeName, rootTypeNameToOperationName } from '../../lib/graphql.js' import { isPlainObject } from '../../lib/prelude.js' import type { SchemaInput } from '../0_functions/requestOrExecute.js' // import { requestOrExecute } from '../0_functions/requestOrExecute.js' @@ -23,7 +23,6 @@ import type { ReturnModeTypeSuccessData, } from './Config.js' import type { DocumentFn } from './document.js' -import { toDocumentString } from './document.js' import type { GetRootTypeMethods } from './RootTypeMethods.js' export interface Context { @@ -165,6 +164,29 @@ export const createInternal = ( */ const returnMode = input.returnMode ?? `data` as ReturnModeType + const executeRootType = async ( + context: TypedContext, + rootTypeName: RootTypeName, + rootTypeSelectionSet: GraphQLObjectSelection, + ) => { + const transport = input.schema instanceof GraphQLSchema ? `memory` : `http` + const interface_ = `typed` + const initialInput = { + interface: interface_, + selection: rootTypeSelectionSet, + context: { + config: context.config, + transport, + interface: interface_, + schemaIndex: context.schemaIndex, + }, + transport, + rootTypeName, + schema: input.schema as string | URL, + } + return await run(context, initialInput) + } + const executeRootTypeField = async ( context: TypedContext, rootTypeName: RootTypeName, @@ -180,14 +202,15 @@ export const createInternal = ( // @ts-expect-error fixme const isSchemaScalarOrTypeName = type.kind === `Scalar` || type.kind === `typename` // todo fix type here, its valid const isSchemaHasArgs = Boolean(context.schemaIndex.Root[rootTypeName]?.fields[key]?.args) - const documentObject = { + const rootTypeSelectionSet = { [rootTypeNameToOperationName[rootTypeName]]: { [key]: isSchemaScalarOrTypeName ? isSchemaHasArgs && argsOrSelectionSet ? { $: argsOrSelectionSet } : true : argsOrSelectionSet, }, } as GraphQLObjectSelection - const result = await executeRootType(context, rootTypeName, documentObject) + const result = await executeRootType(context, rootTypeName, rootTypeSelectionSet) + if (result instanceof Error) return result return context.config.returnMode === `data` || context.config.returnMode === `dataAndErrors` || context.config.returnMode === `successData` // @ts-expect-error @@ -195,75 +218,26 @@ export const createInternal = ( : result } - const executeRootType = async ( - context: TypedContext, - rootTypeName: RootTypeName, - selectionSet: GraphQLObjectSelection, - ) => { - const transport = input.schema instanceof GraphQLSchema ? `memory` : `http` - const interface_ = `typed` - const initialInput = { - interface: interface_, - selection: selectionSet, - context: { - config: context.config, - transport, - interface: interface_, - schemaIndex: context.schemaIndex, - }, - transport, - rootTypeName, - schema: input.schema as string | URL, - } - const result = await run(context, initialInput) - // todo centralize - return handleReturn(context, result) - } - - const createRootType = (context: TypedContext, rootTypeName: RootTypeName) => { - return async (isOrThrow: boolean, selectionSetOrIndicator: GraphQLObjectSelection) => { - const context2 = isOrThrow ? { ...context, config: { ...context.config, returnMode: `successData` } } : context - return await executeRootType(context2, rootTypeName, { - [rootTypeNameToOperationName[rootTypeName]]: selectionSetOrIndicator, - }) - } - } - - const createRootTypeField = (context: TypedContext, rootTypeName: RootTypeName) => { - return async (isOrThrow: boolean, fieldName: string, argsOrSelectionSet?: object) => { - const result = await executeRootTypeField(context, rootTypeName, fieldName, argsOrSelectionSet) // eslint-disable-line - // todo all of the following is return processing, could be lifted out & centralized - if (isOrThrow && result instanceof Error) throw result - // todo consolidate - // eslint-disable-next-line - if (isOrThrow && returnMode === `graphql` && result.errors.length > 0) { - throw new Errors.ContextualAggregateError( - `One or more errors in the execution result.`, - {}, - // eslint-disable-next-line - result.errors, - ) - } - return result - } - } - const createRootTypeMethods = (context: TypedContext, rootTypeName: RootTypeName) => { - const rootType = createRootType(context, rootTypeName) - const rootTypeField = createRootTypeField(context, rootTypeName) return new Proxy({}, { get: (_, key) => { if (typeof key === `symbol`) throw new Error(`Symbols not supported.`) - // todo centralize the orthrow handling here rather than into each method // todo We need to document that in order for this to 100% work none of the user's root type fields can end with "OrThrow". const isOrThrow = key.endsWith(`OrThrow`) + const contextWithReturnModeSet = isOrThrow ? applyOrThrowToConfig(context) : context if (key.startsWith(`$batch`)) { - return (selectionSet) => rootType(isOrThrow, selectionSet) + return async (selectionSetOrIndicator) => { + const rootTypeSelectionSet = { + [rootTypeNameToOperationName[rootTypeName]]: selectionSetOrIndicator, + } + return await executeRootType(contextWithReturnModeSet, rootTypeName, rootTypeSelectionSet) + } } else { const fieldName = isOrThrow ? key.slice(0, -7) : key - return (argsOrSelectionSet) => rootTypeField(isOrThrow, fieldName, argsOrSelectionSet) + return (argsOrSelectionSet) => + executeRootTypeField(contextWithReturnModeSet, rootTypeName, fieldName, argsOrSelectionSet) } }, }) @@ -280,46 +254,54 @@ export const createInternal = ( } const run = async (context: Context, initialInput: HookInputEncode) => { - return await Anyware.runWithExtensions({ + const result = await Anyware.runWithExtensions({ core: context.core, initialInput, extensions: context.extensions, }) + return handleReturn(context, result) + } + + const runRaw = async (context: Context, input2: RawInput) => { + const interface_: InterfaceRaw = `raw` + const transport = input.schema instanceof GraphQLSchema ? `memory` : `http` + const initialInput: HookInputEncode = { + interface: interface_, + document: input2.document, + context: { + // config: context.config, + transport, + interface: interface_, + }, + transport, + schema: input.schema as string | URL, + } + return await run(context, initialInput) } // @ts-expect-error ignoreme const client: Client = { raw: async (input2: RawInput) => { - const interface_: InterfaceRaw = `raw` - const transport = input.schema instanceof GraphQLSchema ? `memory` : `http` - const initialInput: HookInputEncode = { - interface: interface_, - document: input2.document, - context: { - // config: context.config, - transport, - interface: interface_, + const contextWithReturnModeSet = { + ...context, + config: { + ...context.config, + returnMode: `graphql`, }, - transport, - schema: input.schema as string | URL, } - const result = await run(context, initialInput) - return result + return await runRaw(contextWithReturnModeSet, input2) }, rawOrThrow: async ( input2: RawInput, ) => { - const result = await client.raw(input2) - if (result instanceof Error) throw result - // todo consolidate - if (result.errors && result.errors.length > 0) { - throw new Errors.ContextualAggregateError( - `One or more errors in the execution result.`, - {}, - result.errors, - ) + const contextWithReturnModeSet = { + ...context, + config: { + ...context.config, + returnMode: `graphqlSuccess`, + }, } - return result + return await runRaw(contextWithReturnModeSet, input2) }, extend: (extension) => { // todo test that adding extensions returns a copy of client @@ -384,16 +366,21 @@ const handleReturn = ( result: GraffleExecutionResult, ) => { switch (context.config.returnMode) { + case `graphqlSuccess`: case `dataAndErrors`: case `successData`: case `data`: { + console.log(result) if (result instanceof Error || (result.errors && result.errors.length > 0)) { const error = result instanceof Error ? result : (new Errors.ContextualAggregateError( `One or more errors in the execution result.`, {}, result.errors!, )) - if (context.config.returnMode === `data` || context.config.returnMode === `successData`) throw error + if ( + context.config.returnMode === `data` || context.config.returnMode === `successData` + || context.config.returnMode === `graphqlSuccess` + ) throw error return error } if (context.config.returnMode === `successData`) { @@ -408,6 +395,7 @@ const handleReturn = ( // if (!isPlainObject(rootFieldValue)) return new Error(`Expected result field to be an object.`) if (!isPlainObject(rootFieldValue)) return null const __typename = rootFieldValue[`__typename`] + console.log(1) if (typeof __typename !== `string`) throw new Error(`Expected __typename to be selected and a string.`) const isErrorObject = Boolean( context.schemaIndex.error.objectsTypename[__typename], @@ -426,6 +414,9 @@ const handleReturn = ( throw error } } + if (context.config.returnMode === `graphqlSuccess`) { + return result + } return result.data } default: { @@ -433,3 +424,11 @@ const handleReturn = ( } } } + +const applyOrThrowToConfig = <$Context extends Context>(context: $Context): $Context => { + if (context.config.returnMode === `successData` || context.config.returnMode === `graphqlSuccess`) { + return context + } + const newMode = context.config.returnMode === `graphql` ? `graphqlSuccess` : `successData` + return { ...context, config: { ...context.config, returnMode: newMode } } +} From 79c26020539a5ce613b35e1d8b1cc30b5757a4db Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 20 May 2024 22:27:35 -0400 Subject: [PATCH 18/30] work --- src/layers/5_client/client.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index 3f45db07..1a4f0faa 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -209,7 +209,9 @@ export const createInternal = ( : argsOrSelectionSet, }, } as GraphQLObjectSelection + console.log(3, context) const result = await executeRootType(context, rootTypeName, rootTypeSelectionSet) + console.log(result) if (result instanceof Error) return result return context.config.returnMode === `data` || context.config.returnMode === `dataAndErrors` || context.config.returnMode === `successData` From 05c4f373783cbf463825cb43a8ffd014d6e2c47e Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 20 May 2024 22:49:33 -0400 Subject: [PATCH 19/30] all client tests passing now --- .../5_client/client.rootTypeMethods.test.ts | 4 +- src/layers/5_client/client.ts | 38 +++++++++++-------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/layers/5_client/client.rootTypeMethods.test.ts b/src/layers/5_client/client.rootTypeMethods.test.ts index 80800a15..f4c0e401 100644 --- a/src/layers/5_client/client.rootTypeMethods.test.ts +++ b/src/layers/5_client/client.rootTypeMethods.test.ts @@ -37,8 +37,8 @@ describe(`query`, () => { await expect(graffle.query.interface({ onObject2ImplementingInterface: { boolean: true } })).resolves.toEqual({}) }) describe(`orThrow`, () => { - test.only(`without error`, async () => { - await expect(graffle.query.objectWithArgsOrThrow({ $: { id: `x` }, id: true })).resolves.toEqual({ id: `x` }) + test(`without error`, async () => { + await expect(graffle.query.objectWithArgsOrThrow({ $: { id: `x` }, id: true })).resolves.toEqual({ id: `x`, __typename: `Object1` }) }) test(`with error`, async () => { await expect(graffle.query.errorOrThrow()).rejects.toMatchObject(db.errorAggregate) diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index 1a4f0faa..47338b60 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -190,33 +190,43 @@ export const createInternal = ( const executeRootTypeField = async ( context: TypedContext, rootTypeName: RootTypeName, - key: string, + rootTypeFieldName: string, argsOrSelectionSet?: object, ) => { - const type = readMaybeThunk( + const selectedType = readMaybeThunk(input.schemaIndex.Root[rootTypeName]?.fields[rootTypeFieldName]?.type) + const selectedNamedType = readMaybeThunk( // eslint-disable-next-line // @ts-ignore excess depth error - Schema.Output.unwrapToNamed(readMaybeThunk(input.schemaIndex.Root[rootTypeName]?.fields[key]?.type)), + Schema.Output.unwrapToNamed( + selectedType, + ), ) as Schema.Output.Named - if (!type) throw new Error(`${rootTypeName} field not found: ${String(key)}`) // eslint-disable-line + if (!selectedNamedType) throw new Error(`${rootTypeName} field not found: ${String(rootTypeFieldName)}`) // eslint-disable-line // @ts-expect-error fixme - const isSchemaScalarOrTypeName = type.kind === `Scalar` || type.kind === `typename` // todo fix type here, its valid - const isSchemaHasArgs = Boolean(context.schemaIndex.Root[rootTypeName]?.fields[key]?.args) + const isSelectedTypeScalarOrTypeName = selectedNamedType.kind === `Scalar` || selectedNamedType.kind === `typename` // todo fix type here, its valid + const isFieldHasArgs = Boolean(context.schemaIndex.Root[rootTypeName]?.fields[rootTypeFieldName]?.args) + const operationType = rootTypeNameToOperationName[rootTypeName] + // We should only need to add __typename for result type fields, but the return handler doesn't yet know how to look beyond a plain object type so we have to add all those cases here. + const needsTypenameAdded = context.config.returnMode === `successData` + && (selectedNamedType.kind === `Object` || selectedNamedType.kind === `Interface` + || selectedNamedType.kind === `Union`) + const rootTypeFieldSelectionSet = isSelectedTypeScalarOrTypeName + ? isFieldHasArgs && argsOrSelectionSet ? { $: argsOrSelectionSet } : true + : needsTypenameAdded + ? { ...argsOrSelectionSet, __typename: true } + : argsOrSelectionSet + const rootTypeSelectionSet = { - [rootTypeNameToOperationName[rootTypeName]]: { - [key]: isSchemaScalarOrTypeName - ? isSchemaHasArgs && argsOrSelectionSet ? { $: argsOrSelectionSet } : true - : argsOrSelectionSet, + [operationType]: { + [rootTypeFieldName]: rootTypeFieldSelectionSet, }, } as GraphQLObjectSelection - console.log(3, context) const result = await executeRootType(context, rootTypeName, rootTypeSelectionSet) - console.log(result) if (result instanceof Error) return result return context.config.returnMode === `data` || context.config.returnMode === `dataAndErrors` || context.config.returnMode === `successData` // @ts-expect-error - ? result[key] + ? result[rootTypeFieldName] : result } @@ -372,7 +382,6 @@ const handleReturn = ( case `dataAndErrors`: case `successData`: case `data`: { - console.log(result) if (result instanceof Error || (result.errors && result.errors.length > 0)) { const error = result instanceof Error ? result : (new Errors.ContextualAggregateError( `One or more errors in the execution result.`, @@ -397,7 +406,6 @@ const handleReturn = ( // if (!isPlainObject(rootFieldValue)) return new Error(`Expected result field to be an object.`) if (!isPlainObject(rootFieldValue)) return null const __typename = rootFieldValue[`__typename`] - console.log(1) if (typeof __typename !== `string`) throw new Error(`Expected __typename to be selected and a string.`) const isErrorObject = Boolean( context.schemaIndex.error.objectsTypename[__typename], From 1ce9f87ea3703a28e895ea75f82de40ae64cc266 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Tue, 21 May 2024 14:03:21 -0400 Subject: [PATCH 20/30] fix type errors in client --- src/layers/3_SelectionSet/encode.ts | 8 +- src/layers/5_client/client.ts | 195 ++++++++++++++-------------- src/layers/5_core/core.ts | 7 +- src/layers/5_core/types.ts | 17 +-- src/lib/prelude.ts | 1 + 5 files changed, 108 insertions(+), 120 deletions(-) diff --git a/src/layers/3_SelectionSet/encode.ts b/src/layers/3_SelectionSet/encode.ts index 35fd6402..ae514573 100644 --- a/src/layers/3_SelectionSet/encode.ts +++ b/src/layers/3_SelectionSet/encode.ts @@ -51,12 +51,12 @@ export interface Context { export const rootTypeSelectionSet = ( context: Context, - schemaObject: Schema.Object$2, - ss: GraphQLObjectSelection, + objectDef: Schema.Object$2, + selectionSet: GraphQLObjectSelection, operationName: string = ``, ) => { - const operationTypeName = lowerCaseFirstLetter(schemaObject.fields.__typename.type.type) - return `${operationTypeName} ${operationName} { ${resolveObjectLikeFieldValue(context, schemaObject, ss)} }` + const operationTypeName = lowerCaseFirstLetter(objectDef.fields.__typename.type.type) + return `${operationTypeName} ${operationName} { ${resolveObjectLikeFieldValue(context, objectDef, selectionSet)} }` } const resolveDirectives = (fieldValue: FieldValue) => { diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index 47338b60..12684b10 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -1,9 +1,8 @@ import { type ExecutionResult, GraphQLSchema } from 'graphql' import { Anyware } from '../../lib/anyware/__.js' -import type { ErrorAnywareExtensionEntrypoint } from '../../lib/anyware/getEntrypoint.js' import { Errors } from '../../lib/errors/__.js' import type { SomeExecutionResultWithoutErrors } from '../../lib/graphql.js' -import { type RootTypeName, rootTypeNameToOperationName } from '../../lib/graphql.js' +import { type RootTypeName } from '../../lib/graphql.js' import { isPlainObject } from '../../lib/prelude.js' import type { SchemaInput } from '../0_functions/requestOrExecute.js' // import { requestOrExecute } from '../0_functions/requestOrExecute.js' @@ -25,19 +24,27 @@ import type { import type { DocumentFn } from './document.js' import type { GetRootTypeMethods } from './RootTypeMethods.js' +// todo could list specific errors here +// Anyware entrypoint +// Extension +type GraffleExecutionResult = ExecutionResult | Errors.ContextualError + +export type SelectionSetOrIndicator = 0 | 1 | boolean | object + +export type SelectionSetOrArgs = object + export interface Context { - core: any // todo - extensions: Extension[] - schemaIndex?: Schema.Index - config: { - returnMode: ReturnModeType - } + core: Anyware.Core + extensions: Anyware.ExtensionInput[] + config: Config } -export type TypedContext = Omit & { +export type TypedContext = Context & { schemaIndex: Schema.Index } +const isTypedContext = (context: Context): context is TypedContext => `schemaIndex` in context + // todo no config needed? export type ClientRaw<_$Config extends Config> = { raw: (input: RawInput) => Promise @@ -145,7 +152,7 @@ export const create: Create = ( ) => createInternal(input_, { extensions: [] }) interface CreateState { - extensions: Extension[] // todo Graffle extension + extensions: Anyware.ExtensionInput[] } export const createInternal = ( @@ -173,17 +180,17 @@ export const createInternal = ( const interface_ = `typed` const initialInput = { interface: interface_, + transport, selection: rootTypeSelectionSet, + rootTypeName, + schema: input.schema, context: { config: context.config, transport, interface: interface_, schemaIndex: context.schemaIndex, }, - transport, - rootTypeName, - schema: input.schema as string | URL, - } + } as HookInputEncode return await run(context, initialInput) } @@ -193,19 +200,16 @@ export const createInternal = ( rootTypeFieldName: string, argsOrSelectionSet?: object, ) => { - const selectedType = readMaybeThunk(input.schemaIndex.Root[rootTypeName]?.fields[rootTypeFieldName]?.type) + const selectedType = readMaybeThunk(context.schemaIndex.Root[rootTypeName]?.fields[rootTypeFieldName]?.type) const selectedNamedType = readMaybeThunk( // eslint-disable-next-line // @ts-ignore excess depth error - Schema.Output.unwrapToNamed( - selectedType, - ), + Schema.Output.unwrapToNamed(selectedType), ) as Schema.Output.Named if (!selectedNamedType) throw new Error(`${rootTypeName} field not found: ${String(rootTypeFieldName)}`) // eslint-disable-line // @ts-expect-error fixme const isSelectedTypeScalarOrTypeName = selectedNamedType.kind === `Scalar` || selectedNamedType.kind === `typename` // todo fix type here, its valid const isFieldHasArgs = Boolean(context.schemaIndex.Root[rootTypeName]?.fields[rootTypeFieldName]?.args) - const operationType = rootTypeNameToOperationName[rootTypeName] // We should only need to add __typename for result type fields, but the return handler doesn't yet know how to look beyond a plain object type so we have to add all those cases here. const needsTypenameAdded = context.config.returnMode === `successData` && (selectedNamedType.kind === `Object` || selectedNamedType.kind === `Interface` @@ -216,12 +220,9 @@ export const createInternal = ( ? { ...argsOrSelectionSet, __typename: true } : argsOrSelectionSet - const rootTypeSelectionSet = { - [operationType]: { - [rootTypeFieldName]: rootTypeFieldSelectionSet, - }, - } as GraphQLObjectSelection - const result = await executeRootType(context, rootTypeName, rootTypeSelectionSet) + const result = await executeRootType(context, rootTypeName, { + [rootTypeFieldName]: rootTypeFieldSelectionSet, + } as GraphQLObjectSelection) if (result instanceof Error) return result return context.config.returnMode === `data` || context.config.returnMode === `dataAndErrors` || context.config.returnMode === `successData` @@ -237,28 +238,22 @@ export const createInternal = ( // todo We need to document that in order for this to 100% work none of the user's root type fields can end with "OrThrow". const isOrThrow = key.endsWith(`OrThrow`) - const contextWithReturnModeSet = isOrThrow ? applyOrThrowToConfig(context) : context + const contextWithReturnModeSet = isOrThrow ? applyOrThrowToContext(context) : context if (key.startsWith(`$batch`)) { - return async (selectionSetOrIndicator) => { - const rootTypeSelectionSet = { - [rootTypeNameToOperationName[rootTypeName]]: selectionSetOrIndicator, - } - return await executeRootType(contextWithReturnModeSet, rootTypeName, rootTypeSelectionSet) - } + return async (selectionSetOrIndicator: SelectionSetOrIndicator) => + executeRootType(contextWithReturnModeSet, rootTypeName, selectionSetOrIndicator as GraphQLObjectSelection) } else { const fieldName = isOrThrow ? key.slice(0, -7) : key - return (argsOrSelectionSet) => - executeRootTypeField(contextWithReturnModeSet, rootTypeName, fieldName, argsOrSelectionSet) + return (selectionSetOrArgs: SelectionSetOrArgs) => + executeRootTypeField(contextWithReturnModeSet, rootTypeName, fieldName, selectionSetOrArgs) } }, }) } - // todo rename to config - // todo integrate input const context: Context = { - core: Core.create(), + core: Core.create() as any, extensions: state.extensions, config: { returnMode, @@ -270,52 +265,38 @@ export const createInternal = ( core: context.core, initialInput, extensions: context.extensions, - }) + }) as GraffleExecutionResult return handleReturn(context, result) } - const runRaw = async (context: Context, input2: RawInput) => { + const runRaw = async (context: Context, rawInput: RawInput) => { const interface_: InterfaceRaw = `raw` const transport = input.schema instanceof GraphQLSchema ? `memory` : `http` - const initialInput: HookInputEncode = { + const initialInput = { interface: interface_, - document: input2.document, + transport, + document: rawInput.document, + schema: input.schema, context: { - // config: context.config, - transport, - interface: interface_, + config: context.config, }, - transport, - schema: input.schema as string | URL, - } + } as HookInputEncode return await run(context, initialInput) } // @ts-expect-error ignoreme const client: Client = { - raw: async (input2: RawInput) => { - const contextWithReturnModeSet = { - ...context, - config: { - ...context.config, - returnMode: `graphql`, - }, - } - return await runRaw(contextWithReturnModeSet, input2) + raw: async (rawInput: RawInput) => { + const contextWithReturnModeSet = updateContextConfig(context, { returnMode: `graphql` }) + return await runRaw(contextWithReturnModeSet, rawInput) }, rawOrThrow: async ( - input2: RawInput, + rawInput: RawInput, ) => { - const contextWithReturnModeSet = { - ...context, - config: { - ...context.config, - returnMode: `graphqlSuccess`, - }, - } - return await runRaw(contextWithReturnModeSet, input2) + const contextWithReturnModeSet = updateContextConfig(context, { returnMode: `graphqlSuccess` }) + return await runRaw(contextWithReturnModeSet, rawInput) }, - extend: (extension) => { + extend: (extension: Anyware.ExtensionInput) => { // todo test that adding extensions returns a copy of client return createInternal(input, { extensions: [...state.extensions, extension] }) }, @@ -343,8 +324,12 @@ export const createInternal = ( } } const operationName = maybeOperationName ? maybeOperationName : Object.keys(documentObject)[0]! - const operationTypeName = Object.keys(documentObject[operationName])[0] - const selection = documentObject[operationName][operationTypeName] + const rootTypeSelection = documentObject[operationName] + if (!rootTypeSelection) throw new Error(`Operation with name ${operationName} not found.`) + const operationTypeName = Object.keys(rootTypeSelection)[0] + if (!operationTypeName) throw new Error(`Operation has no selection set.`) + // @ts-expect-error + const selection = rootTypeSelection[operationTypeName] return { operationTypeName, selection, @@ -371,10 +356,8 @@ export const createInternal = ( return client } -type GraffleExecutionResult = ExecutionResult | ErrorAnywareExtensionEntrypoint - const handleReturn = ( - context: TypedContext, + context: Context, result: GraffleExecutionResult, ) => { switch (context.config.returnMode) { @@ -394,39 +377,45 @@ const handleReturn = ( ) throw error return error } - if (context.config.returnMode === `successData`) { - if (!isPlainObject(result.data)) throw new Error(`Expected data to be an object.`) - const schemaErrors = Object.entries(result.data).map(([rootFieldName, rootFieldValue]) => { - // todo this check would be nice but it doesn't account for aliases right now. To achieve this we would - // need to have the selection set available to use and then do a costly analysis for all fields that were aliases. - // So costly that we would probably instead want to create an index of them on the initial encoding step and - // then make available down stream. Also, note, here, the hardcoding of Query, needs to be any root type. - // const isResultField = Boolean(schemaIndex.error.rootResultFields.Query[rootFieldName]) - // if (!isResultField) return null - // if (!isPlainObject(rootFieldValue)) return new Error(`Expected result field to be an object.`) - if (!isPlainObject(rootFieldValue)) return null - const __typename = rootFieldValue[`__typename`] - if (typeof __typename !== `string`) throw new Error(`Expected __typename to be selected and a string.`) - const isErrorObject = Boolean( - context.schemaIndex.error.objectsTypename[__typename], - ) - if (!isErrorObject) return null - // todo extract message - return new Error(`Failure on field ${rootFieldName}: ${__typename}`) - }).filter((_): _ is Error => _ !== null) - if (schemaErrors.length === 1) throw schemaErrors[0]! - if (schemaErrors.length > 0) { - const error = new Errors.ContextualAggregateError( - `Two or more schema errors in the execution result.`, - {}, - schemaErrors, - ) - throw error + + if (isTypedContext(context)) { + if (context.config.returnMode === `successData`) { + if (!isPlainObject(result.data)) throw new Error(`Expected data to be an object.`) + const schemaErrors = Object.entries(result.data).map(([rootFieldName, rootFieldValue]) => { + // todo this check would be nice but it doesn't account for aliases right now. To achieve this we would + // need to have the selection set available to use and then do a costly analysis for all fields that were aliases. + // So costly that we would probably instead want to create an index of them on the initial encoding step and + // then make available down stream. Also, note, here, the hardcoding of Query, needs to be any root type. + // const isResultField = Boolean(schemaIndex.error.rootResultFields.Query[rootFieldName]) + // if (!isResultField) return null + // if (!isPlainObject(rootFieldValue)) return new Error(`Expected result field to be an object.`) + if (!isPlainObject(rootFieldValue)) return null + const __typename = rootFieldValue[`__typename`] + if (typeof __typename !== `string`) throw new Error(`Expected __typename to be selected and a string.`) + const isErrorObject = Boolean( + context.schemaIndex.error.objectsTypename[__typename], + ) + if (!isErrorObject) return null + // todo extract message + return new Error(`Failure on field ${rootFieldName}: ${__typename}`) + }).filter((_): _ is Error => _ !== null) + + if (schemaErrors.length === 1) throw schemaErrors[0]! + if (schemaErrors.length > 0) { + const error = new Errors.ContextualAggregateError( + `Two or more schema errors in the execution result.`, + {}, + schemaErrors, + ) + throw error + } } } + if (context.config.returnMode === `graphqlSuccess`) { return result } + return result.data } default: { @@ -435,10 +424,14 @@ const handleReturn = ( } } -const applyOrThrowToConfig = <$Context extends Context>(context: $Context): $Context => { +const applyOrThrowToContext = <$Context extends Context>(context: $Context): $Context => { if (context.config.returnMode === `successData` || context.config.returnMode === `graphqlSuccess`) { return context } const newMode = context.config.returnMode === `graphql` ? `graphqlSuccess` : `successData` - return { ...context, config: { ...context.config, returnMode: newMode } } + return updateContextConfig(context, { returnMode: newMode }) +} + +const updateContextConfig = <$Context extends Context>(context: $Context, config: Config): $Context => { + return { ...context, config: { ...context.config, ...config } } } diff --git a/src/layers/5_core/core.ts b/src/layers/5_core/core.ts index eefb0142..0636d93d 100644 --- a/src/layers/5_core/core.ts +++ b/src/layers/5_core/core.ts @@ -1,7 +1,7 @@ import type { DocumentNode, ExecutionResult, GraphQLSchema } from 'graphql' import { print } from 'graphql' import type { Core } from '../../lib/anyware/main.js' -import { rootTypeNameToOperationName, type StandardScalarVariables } from '../../lib/graphql.js' +import { type StandardScalarVariables } from '../../lib/graphql.js' import { parseExecutionResult } from '../../lib/graphqlHTTP.js' import { CONTENT_TYPE_GQL } from '../../lib/http.js' import { casesExhausted } from '../../lib/prelude.js' @@ -29,7 +29,7 @@ const getRootIndexOrThrow = (context: ContextInterfaceTyped, rootTypeName: strin type InterfaceInput = | ({ interface: InterfaceTyped - context: ContextInterfaceTyped + // context: ContextInterfaceTyped rootTypeName: Schema.RootTypeName } & A) | ({ @@ -113,8 +113,7 @@ export const create = (): Core => { document = SelectionSet.Print.rootTypeSelectionSet( input.context, getRootIndexOrThrow(input.context, input.rootTypeName), - // @ts-expect-error fixme - input.selection[rootTypeNameToOperationName[input.rootTypeName]], + input.selection, ) break } diff --git a/src/layers/5_core/types.ts b/src/layers/5_core/types.ts index f436b83d..61127995 100644 --- a/src/layers/5_core/types.ts +++ b/src/layers/5_core/types.ts @@ -1,8 +1,10 @@ import type { Schema } from '../1_Schema/__.js' -import type { ReturnModeType } from '../5_client/Config.js' +import type { Config } from '../5_client/Config.js' export type Transport = TransportMemory | TransportHttp + export type TransportMemory = 'memory' + export type TransportHttp = 'http' export type Interface = InterfaceRaw | InterfaceTyped @@ -12,18 +14,11 @@ export type InterfaceRaw = 'raw' export type InterfaceTyped = 'typed' type BaseContext = { - transport: Transport - config: { - returnMode: ReturnModeType - } + config: Config } -export type Context = ContextInterfaceTyped | ContextInterfaceRaw - export type ContextInterfaceTyped = & BaseContext - & ({ interface: InterfaceTyped; schemaIndex: Schema.Index }) + & ({ schemaIndex: Schema.Index }) -export type ContextInterfaceRaw = BaseContext & { - interface: InterfaceRaw -} +export type ContextInterfaceRaw = BaseContext diff --git a/src/lib/prelude.ts b/src/lib/prelude.ts index 6a570a71..f89e25b0 100644 --- a/src/lib/prelude.ts +++ b/src/lib/prelude.ts @@ -251,6 +251,7 @@ export type Deferred = { resolve: (value: T) => void reject: (error: unknown) => void } + export const createDeferred = <$T>(): Deferred<$T> => { let resolve let reject From aaed8c553741094b93f1f603491a17c586b00a34 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Tue, 21 May 2024 14:42:27 -0400 Subject: [PATCH 21/30] fix types --- src/layers/5_client/client.extend.test.ts | 4 ++- src/layers/5_client/client.ts | 4 +-- src/layers/5_core/core.ts | 11 +++--- src/lib/anyware/getEntrypoint.ts | 2 +- src/lib/anyware/main.spec.ts | 20 +++++------ src/lib/anyware/main.ts | 43 ++++++++++++++++++----- src/lib/anyware/specHelpers.ts | 5 +-- src/lib/prelude.ts | 15 ++++---- 8 files changed, 65 insertions(+), 39 deletions(-) diff --git a/src/layers/5_client/client.extend.test.ts b/src/layers/5_client/client.extend.test.ts index f5c721fa..f2595a10 100644 --- a/src/layers/5_client/client.extend.test.ts +++ b/src/layers/5_client/client.extend.test.ts @@ -1,8 +1,10 @@ /* eslint-disable */ +import { ExecutionResult } from 'graphql' import { describe, expect } from 'vitest' import { db } from '../../../tests/_/db.js' import { createResponse, test } from '../../../tests/_/helpers.js' import { Graffle } from '../../../tests/_/schema/generated/__.js' +import { GraphQLExecutionResult } from '../../legacy/lib/graphql.js' const client = Graffle.create({ schema: 'https://foo', returnMode: 'dataAndErrors' }) const headers = { 'x-foo': 'bar' } @@ -26,7 +28,7 @@ describe(`entrypoint request`, () => { expect(await client2.query.id()).toEqual(db.id) }) test('can chain into exchange', async ({ fetch }) => { - fetch.mockImplementationOnce(async (input: Request) => { + fetch.mockImplementationOnce(async () => { return createResponse({ data: { id: db.id } }) }) const client2 = client.extend(async ({ pack }) => { diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index 12684b10..83ca6e9f 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -47,8 +47,8 @@ const isTypedContext = (context: Context): context is TypedContext => `schemaInd // todo no config needed? export type ClientRaw<_$Config extends Config> = { - raw: (input: RawInput) => Promise - rawOrThrow: (input: RawInput) => Promise + raw: (input: Omit) => Promise + rawOrThrow: (input: Omit) => Promise } // dprint-ignore diff --git a/src/layers/5_core/core.ts b/src/layers/5_core/core.ts index 0636d93d..ec183029 100644 --- a/src/layers/5_core/core.ts +++ b/src/layers/5_core/core.ts @@ -1,6 +1,6 @@ import type { DocumentNode, ExecutionResult, GraphQLSchema } from 'graphql' import { print } from 'graphql' -import type { Core } from '../../lib/anyware/main.js' +import { Anyware } from '../../lib/anyware/__.js' import { type StandardScalarVariables } from '../../lib/graphql.js' import { parseExecutionResult } from '../../lib/graphqlHTTP.js' import { CONTENT_TYPE_GQL } from '../../lib/http.js' @@ -29,7 +29,7 @@ const getRootIndexOrThrow = (context: ContextInterfaceTyped, rootTypeName: strin type InterfaceInput = | ({ interface: InterfaceTyped - // context: ContextInterfaceTyped + context: ContextInterfaceTyped rootTypeName: Schema.RootTypeName } & A) | ({ @@ -93,9 +93,8 @@ export type Hooks = { } // todo does this need to be a constructor? -export const create = (): Core => { - // todo Get type passing by having a constructor brand the result - return { +export const create = () => { + return Anyware.createCore({ hookNamesOrderedBySequence: [`encode`, `pack`, `exchange`, `unpack`, `decode`], hooks: { encode: ( @@ -250,5 +249,5 @@ export const create = (): Core => { // 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... - } + }) } diff --git a/src/lib/anyware/getEntrypoint.ts b/src/lib/anyware/getEntrypoint.ts index 38ddea54..394daa1c 100644 --- a/src/lib/anyware/getEntrypoint.ts +++ b/src/lib/anyware/getEntrypoint.ts @@ -24,7 +24,7 @@ export const ExtensionEntryHookIssue = { export type ExtensionEntryHookIssue = typeof ExtensionEntryHookIssue[keyof typeof ExtensionEntryHookIssue] export const getEntrypoint = ( - hookNames: string[], + hookNames: readonly string[], extension: ExtensionInput, ): ErrorAnywareExtensionEntrypoint | HookName => { const x = analyzeFunction(extension) diff --git a/src/lib/anyware/main.spec.ts b/src/lib/anyware/main.spec.ts index 7ad95aea..5663c615 100644 --- a/src/lib/anyware/main.spec.ts +++ b/src/lib/anyware/main.spec.ts @@ -90,17 +90,17 @@ describe(`two extensions`, () => { }) test(`each can adjust first hook then passthrough`, async () => { - const ex1 = ({ a }) => a({ value: a.input.value + `+ex1` }) - const ex2 = ({ a }) => a({ value: a.input.value + `+ex2` }) + const ex1 = ({ a }: any) => a({ value: a.input.value + `+ex1` }) + const ex2 = ({ a }: any) => a({ value: a.input.value + `+ex2` }) expect(await run(ex1, ex2)).toEqual({ value: `initial+ex1+ex2+a+b` }) }) test(`each can adjust each hook`, async () => { - const ex1 = async ({ a }) => { + const ex1 = async ({ a }: any) => { const { b } = await a({ value: a.input.value + `+ex1` }) return await b({ value: b.input.value + `+ex1` }) } - const ex2 = async ({ a }) => { + const ex2 = async ({ a }: any) => { const { b } = await a({ value: a.input.value + `+ex2` }) return await b({ value: b.input.value + `+ex2` }) } @@ -108,22 +108,22 @@ describe(`two extensions`, () => { }) test(`second can skip hook a`, async () => { - const ex1 = async ({ a }) => { + const ex1 = async ({ a }: any) => { const { b } = await a({ value: a.input.value + `+ex1` }) return await b({ value: b.input.value + `+ex1` }) } - const ex2 = async ({ b }) => { + const ex2 = async ({ b }: any) => { return await b({ value: b.input.value + `+ex2` }) } expect(await run(ex1, ex2)).toEqual({ value: `initial+ex1+a+ex1+ex2+b` }) }) test(`second can short-circuit before hook a`, async () => { let ex1AfterA = false - const ex1 = async ({ a }) => { + const ex1 = async ({ a }: any) => { const { b } = await a({ value: a.input.value + `+ex1` }) ex1AfterA = true } - const ex2 = async ({ a }) => { + const ex2 = async ({ a }: any) => { return 2 } expect(await run(ex1, ex2)).toEqual(2) @@ -133,12 +133,12 @@ describe(`two extensions`, () => { }) test(`second can short-circuit after hook a`, async () => { let ex1AfterB = false - const ex1 = async ({ a }) => { + const ex1 = async ({ a }: any) => { const { b } = await a({ value: a.input.value + `+ex1` }) await b({ value: b.input.value + `+ex1` }) ex1AfterB = true } - const ex2 = async ({ a }) => { + const ex2 = async ({ a }: any) => { await a({ value: a.input.value + `+ex2` }) return 2 } diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts index 017de3ef..93585334 100644 --- a/src/lib/anyware/main.ts +++ b/src/lib/anyware/main.ts @@ -4,6 +4,7 @@ // E.g.: NOT await request(request.input) // but instead simply: await request() +import { Errors } from '../errors/__.js' import { partitionAndAggregateErrors } from '../errors/ContextualAggregateError.js' import { ContextualError } from '../errors/ContextualError.js' import type { @@ -57,6 +58,14 @@ type SomeHookEnvelope = { } type SomeHook = { [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 + // TODO how do I make this input type object without breaking the final types in e.g. client.extend.test + // Ask Pierre + // (input: object): SomeHookEnvelope + (input: any): any + input: object } type Hook< @@ -83,6 +92,14 @@ type HookReturn< > } +export const createCore = < + $HookSequence extends HookSequence = HookSequence, + $HookMap extends Record<$HookSequence[number], object> = Record<$HookSequence[number], object>, + $Result = unknown, +>(input: Omit, PrivateTypesSymbol>): Core<$HookSequence, $HookMap, $Result> => { + return input as any +} + export type Core< $HookSequence extends HookSequence = HookSequence, $HookMap extends Record<$HookSequence[number], object> = Record<$HookSequence[number], object>, @@ -239,9 +256,12 @@ const runHook = async <$HookName extends string>( // Run core to get result const implementation = core.hooks[name] + if (!implementation) { + throw new Errors.ContextualError(`Implementation not found for hook name ${name}`, { hookName: name }) + } let result try { - result = await implementation(originalInput) + result = await implementation(originalInput as any) } catch (error) { done({ type: `error`, hookName: name, source: `implementation`, error: errorFromMaybeError(error) }) return @@ -324,14 +344,18 @@ const run = async ( return currentResult // last loop result } -const createPassthrough = (hookName: string) => async (hookEnvelope) => { - return await hookEnvelope[hookName](hookEnvelope[hookName].input) +const createPassthrough = (hookName: string) => async (hookEnvelope: SomeHookEnvelope) => { + const hook = hookEnvelope[hookName] + if (!hook) { + throw new Errors.ContextualError(`Hook not found in hook envelope`, { hookName }) + } + return await hook(hook.input) } const toInternalExtension = (core: Core, config: Config, extension: SomeAsyncFunction) => { - const currentChunk = createDeferred() + const currentChunk = createDeferred() const body = createDeferred() - const applyBody = async (input) => { + const applyBody = async (input: object) => { try { const result = await extension(input) body.resolve(result) @@ -344,10 +368,10 @@ const toInternalExtension = (core: Core, config: Config, extension: SomeAsyncFun switch (config.entrypointSelectionMode) { case `off`: { - currentChunk.promise.then(applyBody) + void currentChunk.promise.then(applyBody) return { name: extensionName, - entrypoint: core.hookNamesOrderedBySequence[0], // todo non-empty-array datastructure + entrypoint: core.hookNamesOrderedBySequence[0], // todo non-empty-array data structure body, currentChunk, } @@ -359,7 +383,7 @@ const toInternalExtension = (core: Core, config: Config, extension: SomeAsyncFun if (config.entrypointSelectionMode === `required`) { return entrypoint } else { - currentChunk.promise.then(applyBody) + void currentChunk.promise.then(applyBody) return { name: extensionName, entrypoint: core.hookNamesOrderedBySequence[0], // todo non-empty-array data structure @@ -380,7 +404,7 @@ const toInternalExtension = (core: Core, config: Config, extension: SomeAsyncFun for (const passthrough of passthroughs) { currentChunkPromiseChain = currentChunkPromiseChain.then(passthrough) } - currentChunkPromiseChain.then(applyBody) + void currentChunkPromiseChain.then(applyBody) return { name: extensionName, @@ -428,6 +452,7 @@ export const runWithExtensions = async <$Core extends Core>( return await run({ core, initialInput, + // @ts-expect-error fixme initialHookStack, }) } diff --git a/src/lib/anyware/specHelpers.ts b/src/lib/anyware/specHelpers.ts index f2e0f60b..7a91a430 100644 --- a/src/lib/anyware/specHelpers.ts +++ b/src/lib/anyware/specHelpers.ts @@ -1,5 +1,6 @@ import type { Mock } from 'vitest' import { beforeEach, vi } from 'vitest' +import { Anyware } from './__.js' import { type Core, type ExtensionInput, type Options, runWithExtensions } from './main.js' export type Input = { value: string } @@ -22,10 +23,10 @@ export const createCore = (): $Core => { return { value: input.value + `+b` } }) - return { + return Anyware.createCore({ hookNamesOrderedBySequence: [`a`, `b`], hooks: { a, b }, - } + }) as $Core } beforeEach(() => { diff --git a/src/lib/prelude.ts b/src/lib/prelude.ts index f89e25b0..9cfa5bfb 100644 --- a/src/lib/prelude.ts +++ b/src/lib/prelude.ts @@ -1,7 +1,4 @@ -import { resolve } from 'dns' import type { ConditionalSimplifyDeep } from 'type-fest/source/conditional-simplify.js' -import { Errors } from './errors/__.js' -import type { ContextualAggregateError } from './errors/ContextualAggregateError.js' /* eslint-disable */ export type RemoveIndex = { @@ -253,16 +250,18 @@ export type Deferred = { } export const createDeferred = <$T>(): Deferred<$T> => { - let resolve - let reject - const promise = new Promise(($resolve, $reject) => { + let resolve: (value: $T) => void + let reject: (error: unknown) => void + + const promise = new Promise<$T>(($resolve, $reject) => { resolve = $resolve reject = $reject }) + return { promise, - resolve, - reject, + resolve: (value) => resolve(value), + reject: (error) => reject(error), } } From da2ad5905e2f81c9fa390a07d5e9e31550caeda7 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Tue, 21 May 2024 16:58:49 -0400 Subject: [PATCH 22/30] lint --- src/layers/0_functions/execute.ts | 2 +- src/layers/0_functions/requestOrExecute.ts | 23 -------------- src/layers/5_client/RootTypeMethods.ts | 4 +-- src/layers/5_client/client.ts | 35 ++++++++++++++-------- src/layers/5_core/core.ts | 6 ++++ src/legacy/helpers/runRequest.ts | 4 +-- src/lib/analyzeFunction.ts | 2 +- src/lib/anyware/main.entrypoint.spec.ts | 2 ++ src/lib/anyware/main.spec.ts | 2 ++ src/lib/anyware/main.ts | 25 +++++++--------- src/lib/graphql.ts | 5 +++- src/lib/prelude.ts | 4 +-- 12 files changed, 55 insertions(+), 59 deletions(-) diff --git a/src/layers/0_functions/execute.ts b/src/layers/0_functions/execute.ts index 141153ca..a97bc247 100644 --- a/src/layers/0_functions/execute.ts +++ b/src/layers/0_functions/execute.ts @@ -6,7 +6,7 @@ interface Input extends BaseInput { schema: GraphQLSchema } -export const execute = async (input: Input): Promise> => { +export const execute = async (input: Input): Promise => { switch (typeof input.document) { case `string`: { return await graphql({ diff --git a/src/layers/0_functions/requestOrExecute.ts b/src/layers/0_functions/requestOrExecute.ts index b4978ac7..e69de29b 100644 --- a/src/layers/0_functions/requestOrExecute.ts +++ b/src/layers/0_functions/requestOrExecute.ts @@ -1,23 +0,0 @@ -import type { ExecutionResult, GraphQLSchema } from 'graphql' -import { execute } from './execute.js' -import type { URLInput } from './request.js' -import { request } from './request.js' -import type { BaseInput } from './types.js' - -export type SchemaInput = URLInput | GraphQLSchema - -export interface Input extends BaseInput { - schema: SchemaInput -} - -export const requestOrExecute = async ( - input: Input, -): Promise => { - const { schema, ...baseInput } = input - - if (schema instanceof URL || typeof schema === `string`) { - return await request({ url: schema, ...baseInput }) - } - - return await execute({ schema, ...baseInput }) -} diff --git a/src/layers/5_client/RootTypeMethods.ts b/src/layers/5_client/RootTypeMethods.ts index e471e25e..33f7a9c7 100644 --- a/src/layers/5_client/RootTypeMethods.ts +++ b/src/layers/5_client/RootTypeMethods.ts @@ -1,4 +1,4 @@ -import type { OperationName } from '../../lib/graphql.js' +import type { OperationTypeName } from '../../lib/graphql.js' import type { Exact } from '../../lib/prelude.js' import type { TSError } from '../../lib/TSError.js' import type { InputFieldsAllNullable, Schema } from '../1_Schema/__.js' @@ -23,7 +23,7 @@ type RootTypeFieldContext = { // dprint-ignore export type GetRootTypeMethods<$Config extends Config, $Index extends Schema.Index> = { - [$OperationName in OperationName as $Index['Root'][Capitalize<$OperationName>] extends null ? never : $OperationName]: + [$OperationName in OperationTypeName as $Index['Root'][Capitalize<$OperationName>] extends null ? never : $OperationName]: RootTypeMethods<$Config, $Index, Capitalize<$OperationName>> } diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index 83ca6e9f..1c106852 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -2,11 +2,10 @@ import { type ExecutionResult, GraphQLSchema } from 'graphql' import { Anyware } from '../../lib/anyware/__.js' import { Errors } from '../../lib/errors/__.js' import type { SomeExecutionResultWithoutErrors } from '../../lib/graphql.js' -import { type RootTypeName } from '../../lib/graphql.js' +import { isOperationTypeName, operationTypeNameToRootTypeName, type RootTypeName } from '../../lib/graphql.js' import { isPlainObject } from '../../lib/prelude.js' -import type { SchemaInput } from '../0_functions/requestOrExecute.js' -// import { requestOrExecute } from '../0_functions/requestOrExecute.js' -import type { Input as RawInput } from '../0_functions/requestOrExecute.js' +import type { URLInput } from '../0_functions/request.js' +import type { BaseInput } from '../0_functions/types.js' import { Schema } from '../1_Schema/__.js' import { readMaybeThunk } from '../1_Schema/core/helpers.js' import type { GlobalRegistry } from '../2_generator/globalRegistry.js' @@ -24,6 +23,12 @@ import type { import type { DocumentFn } from './document.js' import type { GetRootTypeMethods } from './RootTypeMethods.js' +export type SchemaInput = URLInput | GraphQLSchema + +export interface RawInput extends BaseInput { + schema: SchemaInput +} + // todo could list specific errors here // Anyware entrypoint // Extension @@ -253,7 +258,7 @@ export const createInternal = ( } const context: Context = { - core: Core.create() as any, + core: Core.create() as any, // eslint-disable-line extensions: state.extensions, config: { returnMode, @@ -312,6 +317,7 @@ export const createInternal = ( Object.assign(client, { document: (documentObject: DocumentObject) => { const hasMultipleOperations = Object.keys(documentObject).length > 1 + const processInput = (maybeOperationName: string) => { if (!maybeOperationName && hasMultipleOperations) { throw { @@ -327,22 +333,27 @@ export const createInternal = ( const rootTypeSelection = documentObject[operationName] if (!rootTypeSelection) throw new Error(`Operation with name ${operationName} not found.`) const operationTypeName = Object.keys(rootTypeSelection)[0] - if (!operationTypeName) throw new Error(`Operation has no selection set.`) + if (!isOperationTypeName(operationTypeName)) throw new Error(`Operation has no selection set.`) // @ts-expect-error - const selection = rootTypeSelection[operationTypeName] + const selection = rootTypeSelection[operationTypeName] as GraphQLObjectSelection return { - operationTypeName, + rootTypeName: operationTypeNameToRootTypeName[operationTypeName], selection, } } + return { run: async (maybeOperationName: string) => { - const { selection, operationTypeName } = processInput(maybeOperationName) - return await client[operationTypeName].$batch(selection) + const { selection, rootTypeName } = processInput(maybeOperationName) + return await executeRootType(typedContext, rootTypeName, selection) }, runOrThrow: async (maybeOperationName: string) => { - const { selection, operationTypeName } = processInput(maybeOperationName) - return await client[operationTypeName].$batchOrThrow(selection) + const { selection, rootTypeName } = processInput(maybeOperationName) + return await executeRootType( + applyOrThrowToContext(typedContext), + rootTypeName, + selection, + ) }, } }, diff --git a/src/layers/5_core/core.ts b/src/layers/5_core/core.ts index ec183029..8ee848d8 100644 --- a/src/layers/5_core/core.ts +++ b/src/layers/5_core/core.ts @@ -21,11 +21,13 @@ import type { const getRootIndexOrThrow = (context: ContextInterfaceTyped, rootTypeName: string) => { // @ts-expect-error + // eslint-disable-next-line const rootIndex = context.schemaIndex.Root[rootTypeName] if (!rootIndex) throw new Error(`Root type not found: ${rootTypeName}`) return rootIndex } +// eslint-disable-next-line type InterfaceInput = | ({ interface: InterfaceTyped @@ -37,6 +39,7 @@ type InterfaceInput = context: ContextInterfaceRaw } & B) +// eslint-disable-next-line type TransportInput = | ({ transport: TransportHttp @@ -61,6 +64,7 @@ export type HookInputPack = } & InterfaceInput & TransportInput<{ url: string | URL; headers?: HeadersInit }, { schema: GraphQLSchema }> + export type ExchangeInputHook = & InterfaceInput & TransportInput< @@ -72,6 +76,7 @@ export type ExchangeInputHook = operationName?: string } > + export type HookInputUnpack = & InterfaceInput & TransportInput< @@ -80,6 +85,7 @@ export type HookInputUnpack = result: ExecutionResult } > + export type HookInputDecode = & { result: ExecutionResult } & InterfaceInput diff --git a/src/legacy/helpers/runRequest.ts b/src/legacy/helpers/runRequest.ts index c68e467b..ae0f2f33 100644 --- a/src/legacy/helpers/runRequest.ts +++ b/src/legacy/helpers/runRequest.ts @@ -205,7 +205,7 @@ const buildBody = (params: Input) => { variables, })) default: - throw casesExhausted(params.request) // eslint-disable-line + throw casesExhausted(params.request) } } @@ -234,6 +234,6 @@ const buildQueryParams = (params: Input): URLSearchParams => { return searchParams } default: - throw casesExhausted(params.request) // eslint-disable-line + throw casesExhausted(params.request) } } diff --git a/src/lib/analyzeFunction.ts b/src/lib/analyzeFunction.ts index eca7e7be..d51db690 100644 --- a/src/lib/analyzeFunction.ts +++ b/src/lib/analyzeFunction.ts @@ -36,7 +36,7 @@ export const analyzeFunction = (fn: (...args: [...any[]]) => unknown) => { case null: throw new Error(`Could not determine type of parameter.`) default: - throw casesExhausted(type) // eslint-disable-line + throw casesExhausted(type) } }) diff --git a/src/lib/anyware/main.entrypoint.spec.ts b/src/lib/anyware/main.entrypoint.spec.ts index 0aa8f784..92c5d848 100644 --- a/src/lib/anyware/main.entrypoint.spec.ts +++ b/src/lib/anyware/main.entrypoint.spec.ts @@ -1,3 +1,5 @@ +/* eslint-disable */ + import { describe, expect, test } from 'vitest' import type { ContextualAggregateError } from '../errors/ContextualAggregateError.js' import { run } from './specHelpers.js' diff --git a/src/lib/anyware/main.spec.ts b/src/lib/anyware/main.spec.ts index 5663c615..a0e5c0cf 100644 --- a/src/lib/anyware/main.spec.ts +++ b/src/lib/anyware/main.spec.ts @@ -1,3 +1,5 @@ +/* eslint-disable */ + import { describe, expect, test, vi } from 'vitest' import type { ContextualError } from '../errors/ContextualError.js' import { core, oops, run, runWithOptions } from './specHelpers.js' diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts index 93585334..c91b4d91 100644 --- a/src/lib/anyware/main.ts +++ b/src/lib/anyware/main.ts @@ -1,4 +1,4 @@ -// todo allow hooks to have implementation overriden. +// todo allow hooks to have implementation overridden. // E.g.: request((input) => {...}) // todo allow hooks to have passthrough without explicit input passing // E.g.: NOT await request(request.input) @@ -7,14 +7,7 @@ import { Errors } from '../errors/__.js' import { partitionAndAggregateErrors } from '../errors/ContextualAggregateError.js' import { ContextualError } from '../errors/ContextualError.js' -import type { - Deferred, - FindValueAfter, - IsLastValue, - MaybePromise, - SomeAsyncFunction, - SomeMaybeAsyncFunction, -} from '../prelude.js' +import type { Deferred, FindValueAfter, IsLastValue, MaybePromise, SomeMaybeAsyncFunction } from '../prelude.js' import { casesExhausted, createDeferred, debug, errorFromMaybeError } from '../prelude.js' import { getEntrypoint } from './getEntrypoint.js' @@ -180,7 +173,7 @@ const runHook = async <$HookName extends string>( } const nextNextHookStack = [...nextHookStack, nextPausedExtension] // tempting to mutate here but simpler to think about as copy. - runHook({ + void runHook({ core, name, done, @@ -215,7 +208,7 @@ const runHook = async <$HookName extends string>( switch (branch) { case `body`: { if (result === envelope) { - runHook({ + void runHook({ core, name, done, @@ -282,7 +275,7 @@ const run = async ( for (const hookName of core.hookNamesOrderedBySequence) { debug(`running hook`, hookName) const doneDeferred = createDeferred() - runHook({ + void runHook({ core, name: hookName, done: doneDeferred.resolve, @@ -323,10 +316,12 @@ const run = async ( const message = `There was an error in the core implementation of hook "${signal.hookName}".` return new ContextualError(message, { hookName: signal.hookName, source: signal.source }, signal.error) } + default: + throw casesExhausted(signal) } } default: - casesExhausted(signal) + throw casesExhausted(signal) } } @@ -352,7 +347,7 @@ const createPassthrough = (hookName: string) => async (hookEnvelope: SomeHookEnv return await hook(hook.input) } -const toInternalExtension = (core: Core, config: Config, extension: SomeAsyncFunction) => { +const toInternalExtension = (core: Core, config: Config, extension: SomeMaybeAsyncFunction) => { const currentChunk = createDeferred() const body = createDeferred() const applyBody = async (input: object) => { @@ -402,7 +397,7 @@ const toInternalExtension = (core: Core, config: Config, extension: SomeAsyncFun const passthroughs = hooksBeforeEntrypoint.map((hookName) => createPassthrough(hookName)) let currentChunkPromiseChain = currentChunk.promise for (const passthrough of passthroughs) { - currentChunkPromiseChain = currentChunkPromiseChain.then(passthrough) + currentChunkPromiseChain = currentChunkPromiseChain.then(passthrough) // eslint-disable-line } void currentChunkPromiseChain.then(applyBody) diff --git a/src/lib/graphql.ts b/src/lib/graphql.ts index 74b7f459..73c8a77c 100644 --- a/src/lib/graphql.ts +++ b/src/lib/graphql.ts @@ -249,7 +249,10 @@ export type StandardScalarVariables = { export type GraphQLExecutionResultError = Errors.ContextualAggregateError -export type OperationName = 'query' | 'mutation' +export type OperationTypeName = 'query' | 'mutation' + +export const isOperationTypeName = (value: unknown): value is OperationTypeName => + value === `query` || value === `mutation` export interface SomeExecutionResultWithoutErrors< TData = ObjMap, diff --git a/src/lib/prelude.ts b/src/lib/prelude.ts index 9cfa5bfb..3ad6f4f3 100644 --- a/src/lib/prelude.ts +++ b/src/lib/prelude.ts @@ -239,9 +239,9 @@ export type MaybePromise = T | Promise export const capitalizeFirstLetter = (string: string) => string.charAt(0).toUpperCase() + string.slice(1) -export type SomeAsyncFunction = (...args: any[]) => Promise +export type SomeAsyncFunction = (...args: unknown[]) => Promise -export type SomeMaybeAsyncFunction = (...args: any[]) => MaybePromise +export type SomeMaybeAsyncFunction = (...args: unknown[]) => MaybePromise export type Deferred = { promise: Promise From 558abd33bb3f3e4ed73a3b75b483774458234089 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Tue, 21 May 2024 17:03:42 -0400 Subject: [PATCH 23/30] format --- src/lib/analyzeFunction.ts | 2 +- src/lib/anyware/main.entrypoint.spec.ts | 1 + src/lib/anyware/main.ts | 6 +++--- src/lib/anyware/specHelpers.ts | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/lib/analyzeFunction.ts b/src/lib/analyzeFunction.ts index d51db690..fd105f84 100644 --- a/src/lib/analyzeFunction.ts +++ b/src/lib/analyzeFunction.ts @@ -36,7 +36,7 @@ export const analyzeFunction = (fn: (...args: [...any[]]) => unknown) => { case null: throw new Error(`Could not determine type of parameter.`) default: - throw casesExhausted(type) + throw casesExhausted(type) } }) diff --git a/src/lib/anyware/main.entrypoint.spec.ts b/src/lib/anyware/main.entrypoint.spec.ts index 92c5d848..681579ae 100644 --- a/src/lib/anyware/main.entrypoint.spec.ts +++ b/src/lib/anyware/main.entrypoint.spec.ts @@ -44,6 +44,7 @@ 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, diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts index c91b4d91..8f543b7b 100644 --- a/src/lib/anyware/main.ts +++ b/src/lib/anyware/main.ts @@ -7,7 +7,7 @@ import { Errors } from '../errors/__.js' import { partitionAndAggregateErrors } from '../errors/ContextualAggregateError.js' import { ContextualError } from '../errors/ContextualError.js' -import type { Deferred, FindValueAfter, IsLastValue, MaybePromise, SomeMaybeAsyncFunction } from '../prelude.js' +import type { Deferred, FindValueAfter, IsLastValue, MaybePromise } from '../prelude.js' import { casesExhausted, createDeferred, debug, errorFromMaybeError } from '../prelude.js' import { getEntrypoint } from './getEntrypoint.js' @@ -132,7 +132,7 @@ type Extension = { currentChunk: Deferred } -export type ExtensionInput = SomeMaybeAsyncFunction +export type ExtensionInput<$Input extends object = object> = (input: $Input) => MaybePromise type HookDoneData = | { type: 'completed'; result: unknown; nextHookStack: Extension[] } @@ -347,7 +347,7 @@ const createPassthrough = (hookName: string) => async (hookEnvelope: SomeHookEnv return await hook(hook.input) } -const toInternalExtension = (core: Core, config: Config, extension: SomeMaybeAsyncFunction) => { +const toInternalExtension = (core: Core, config: Config, extension: ExtensionInput) => { const currentChunk = createDeferred() const body = createDeferred() const applyBody = async (input: object) => { diff --git a/src/lib/anyware/specHelpers.ts b/src/lib/anyware/specHelpers.ts index 7a91a430..00efcd95 100644 --- a/src/lib/anyware/specHelpers.ts +++ b/src/lib/anyware/specHelpers.ts @@ -43,6 +43,6 @@ export const runWithOptions = (options: Options = {}) => async (...extensions: E return result } -export const run = async (...extensions: ExtensionInput[]) => runWithOptions({})(...extensions) +export const run = async (...extensions: ExtensionInput[]) => runWithOptions({})(...extensions) export const oops = new Error(`oops`) From ae1c5aac2a50ff4f85b5c7273e12c1bec357059c Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Tue, 21 May 2024 17:14:49 -0400 Subject: [PATCH 24/30] try fix --- src/layers/2_generator/globalRegistry.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/layers/2_generator/globalRegistry.ts b/src/layers/2_generator/globalRegistry.ts index e2e1a0ae..5500729e 100644 --- a/src/layers/2_generator/globalRegistry.ts +++ b/src/layers/2_generator/globalRegistry.ts @@ -6,6 +6,8 @@ declare global { export namespace GraphQLRequestTypes { interface Schemas { } + // This is for type testing + interface SchemasAlwaysEmpty {} } } @@ -19,7 +21,10 @@ export type GlobalRegistry = Record + + export type IsEmpty = keyof Schemas extends never ? true : false + + export type SchemaList = IsEmpty extends true ? any : Values export type DefaultSchemaName = 'default' From f5793fa587930be479c89a43f499c2e2494e6b73 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Tue, 21 May 2024 17:22:31 -0400 Subject: [PATCH 25/30] try fix --- src/layers/2_generator/globalRegistry.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/layers/2_generator/globalRegistry.ts b/src/layers/2_generator/globalRegistry.ts index 5500729e..2545793c 100644 --- a/src/layers/2_generator/globalRegistry.ts +++ b/src/layers/2_generator/globalRegistry.ts @@ -4,27 +4,35 @@ import type { Schema } from '../1_Schema/__.js' declare global { export namespace GraphQLRequestTypes { - interface Schemas { - } - // This is for type testing + interface Schemas {} + // This is for manual internal type testing interface SchemasAlwaysEmpty {} } } -export type GlobalRegistry = Record featureOptions: { schemaErrors: boolean } -}> +} + +type ZeroSchema = { + index: { name: never } + featureOptions: { + schemaErrors: false + } +} + +export type GlobalRegistry = Record export namespace GlobalRegistry { - export type Schemas = GraphQLRequestTypes.Schemas + export type Schemas = GraphQLRequestTypes.SchemasAlwaysEmpty export type IsEmpty = keyof Schemas extends never ? true : false - export type SchemaList = IsEmpty extends true ? any : Values + export type SchemaList = IsEmpty extends true ? ZeroSchema : Values export type DefaultSchemaName = 'default' From 50daf90818d7dab57f6a764f04285ba1c5f1d231 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Tue, 21 May 2024 17:23:39 -0400 Subject: [PATCH 26/30] try fix --- src/layers/2_generator/globalRegistry.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/layers/2_generator/globalRegistry.ts b/src/layers/2_generator/globalRegistry.ts index 2545793c..97036ff6 100644 --- a/src/layers/2_generator/globalRegistry.ts +++ b/src/layers/2_generator/globalRegistry.ts @@ -5,8 +5,8 @@ import type { Schema } from '../1_Schema/__.js' declare global { export namespace GraphQLRequestTypes { interface Schemas {} - // This is for manual internal type testing - interface SchemasAlwaysEmpty {} + // Use this is for manual internal type testing. + // interface SchemasAlwaysEmpty {} } } @@ -28,7 +28,7 @@ type ZeroSchema = { export type GlobalRegistry = Record export namespace GlobalRegistry { - export type Schemas = GraphQLRequestTypes.SchemasAlwaysEmpty + export type Schemas = GraphQLRequestTypes.Schemas export type IsEmpty = keyof Schemas extends never ? true : false From ce5315b6fac621af65cdadd2ba7f9c5d00d31a19 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Tue, 21 May 2024 22:44:12 -0400 Subject: [PATCH 27/30] refactor --- src/layers/5_client/client.ts | 2 +- src/layers/5_core/core.ts | 279 +++++++++++++++++----------------- src/lib/anyware/main.ts | 10 +- 3 files changed, 145 insertions(+), 146 deletions(-) diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index 1c106852..fa15bceb 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -258,7 +258,7 @@ export const createInternal = ( } const context: Context = { - core: Core.create() as any, // eslint-disable-line + core: Core.create as any, // eslint-disable-line extensions: state.extensions, config: { returnMode, diff --git a/src/layers/5_core/core.ts b/src/layers/5_core/core.ts index 8ee848d8..b0fc3b98 100644 --- a/src/layers/5_core/core.ts +++ b/src/layers/5_core/core.ts @@ -48,9 +48,9 @@ type TransportInput = transport: TransportMemory } & B) -export const hookSequence = [`encode`, `pack`, `exchange`, `unpack`, `decode`] as const +export const hookNamesOrderedBySequence = [`encode`, `pack`, `exchange`, `unpack`, `decode`] as const -export type HookSequence = typeof hookSequence +export type HookSequence = typeof hookNamesOrderedBySequence export type HookInputEncode = & InterfaceInput<{ selection: GraphQLObjectSelection }, { document: string | DocumentNode }> @@ -98,162 +98,159 @@ export type Hooks = { decode: HookInputDecode } -// todo does this need to be a constructor? -export const create = () => { - return Anyware.createCore({ - hookNamesOrderedBySequence: [`encode`, `pack`, `exchange`, `unpack`, `decode`], - hooks: { - encode: ( - input, - ) => { - // console.log(`encode:1`) - let document: string | DocumentNode - switch (input.interface) { - case `raw`: { - document = input.document - break - } - case `typed`: { - // todo turn inputs into variables - document = SelectionSet.Print.rootTypeSelectionSet( - input.context, - getRootIndexOrThrow(input.context, input.rootTypeName), - input.selection, - ) - break - } - default: - throw casesExhausted(input) +export const create = Anyware.createCore({ + hookNamesOrderedBySequence, + hooks: { + encode: ( + input, + ) => { + // console.log(`encode:1`) + let document: string | DocumentNode + switch (input.interface) { + case `raw`: { + document = input.document + break } + case `typed`: { + // todo turn inputs into variables + document = SelectionSet.Print.rootTypeSelectionSet( + input.context, + getRootIndexOrThrow(input.context, input.rootTypeName), + input.selection, + ) + break + } + default: + throw casesExhausted(input) + } - // console.log(`encode:2`) - switch (input.transport) { - case `http`: { - return { - ...input, - transport: input.transport, - url: input.schema, - document, - variables: {}, - // operationName: '', - } + // console.log(`encode:2`) + switch (input.transport) { + case `http`: { + return { + ...input, + transport: input.transport, + url: input.schema, + document, + variables: {}, + // operationName: '', } - case `memory`: { - return { - ...input, - transport: input.transport, - schema: input.schema, - document, - variables: {}, - // operationName: '', - } + } + case `memory`: { + return { + ...input, + transport: input.transport, + schema: input.schema, + document, + variables: {}, + // operationName: '', } } - }, - pack: (input) => { - // console.log(`pack:1`) - const documentPrinted = typeof input.document === `string` - ? input.document - : print(input.document) + } + }, + pack: (input) => { + // console.log(`pack:1`) + const documentPrinted = typeof input.document === `string` + ? input.document + : print(input.document) - switch (input.transport) { - case `http`: { - const body = { - query: documentPrinted, - variables: input.variables, - operationName: input.operationName, - } + switch (input.transport) { + case `http`: { + const body = { + query: documentPrinted, + variables: input.variables, + operationName: input.operationName, + } - const bodyEncoded = JSON.stringify(body) + const bodyEncoded = JSON.stringify(body) - const requestConfig = new Request(input.url, { - method: `POST`, - headers: new Headers({ - 'accept': CONTENT_TYPE_GQL, - ...Object.fromEntries(new Headers(input.headers).entries()), - }), - body: bodyEncoded, - }) + const requestConfig = new Request(input.url, { + method: `POST`, + headers: new Headers({ + 'accept': CONTENT_TYPE_GQL, + ...Object.fromEntries(new Headers(input.headers).entries()), + }), + body: bodyEncoded, + }) - return { - ...input, - request: requestConfig, - } + return { + ...input, + request: requestConfig, } - case `memory`: { - return { - ...input, - } - } - default: - throw casesExhausted(input) } - }, - exchange: async (input) => { - switch (input.transport) { - case `http`: { - const response = await fetch(input.request) - return { - ...input, - response, - } - } - case `memory`: { - const result = await execute({ - schema: input.schema, - document: input.document, - variables: input.variables, - operationName: input.operationName, - }) - return { - ...input, - result, - } + case `memory`: { + return { + ...input, } - default: - throw casesExhausted(input) } - }, - unpack: async (input) => { - switch (input.transport) { - case `http`: { - const json = await input.response.json() as object - const result = parseExecutionResult(json) - return { - ...input, - result, - } + default: + throw casesExhausted(input) + } + }, + exchange: async (input) => { + switch (input.transport) { + case `http`: { + const response = await fetch(input.request) + return { + ...input, + response, } - case `memory`: { - return { - ...input, - result: input.result, - } + } + case `memory`: { + const result = await execute({ + schema: input.schema, + document: input.document, + variables: input.variables, + operationName: input.operationName, + }) + return { + ...input, + result, } - default: - throw casesExhausted(input) } - }, - decode: (input) => { - switch (input.interface) { - // todo this depends on the return mode - case `raw`: { - return input.result + default: + throw casesExhausted(input) + } + }, + unpack: async (input) => { + switch (input.transport) { + case `http`: { + const json = await input.response.json() as object + const result = parseExecutionResult(json) + return { + ...input, + result, } - case `typed`: { - // todo optimize - // 1. Generate a map of possible custom scalar paths (tree structure) - // 2. When traversing the result, skip keys that are not in the map - const dataDecoded = Result.decode(getRootIndexOrThrow(input.context, input.rootTypeName), input.result.data) - return { ...input.result, data: dataDecoded } + } + case `memory`: { + return { + ...input, + result: input.result, } - default: - throw casesExhausted(input) } - }, + default: + throw casesExhausted(input) + } }, - // 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... - }) -} + decode: (input) => { + switch (input.interface) { + // todo this depends on the return mode + case `raw`: { + return input.result + } + case `typed`: { + // todo optimize + // 1. Generate a map of possible custom scalar paths (tree structure) + // 2. When traversing the result, skip keys that are not in the map + const dataDecoded = Result.decode(getRootIndexOrThrow(input.context, input.rootTypeName), input.result.data) + return { ...input.result, data: dataDecoded } + } + default: + throw casesExhausted(input) + } + }, + }, + // 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... +}) diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts index 8f543b7b..8b7b2106 100644 --- a/src/lib/anyware/main.ts +++ b/src/lib/anyware/main.ts @@ -61,9 +61,11 @@ type SomeHook = { input: object } +type HookMap<$HookSequence extends HookSequence> = Record<$HookSequence[number], object> + type Hook< $HookSequence extends HookSequence, - $HookMap extends Record<$HookSequence[number], object> = Record<$HookSequence[number], object>, + $HookMap extends HookMap<$HookSequence> = HookMap<$HookSequence>, $Result = unknown, $Name extends $HookSequence[number] = $HookSequence[number], > = (<$$Input extends $HookMap[$Name]>(input: $$Input) => HookReturn<$HookSequence, $HookMap, $Result, $Name>) & { @@ -73,7 +75,7 @@ type Hook< type HookReturn< $HookSequence extends HookSequence, - $HookMap extends Record<$HookSequence[number], object> = Record<$HookSequence[number], object>, + $HookMap extends HookMap<$HookSequence> = HookMap<$HookSequence>, $Result = unknown, $Name extends $HookSequence[number] = $HookSequence[number], > = IsLastValue<$Name, $HookSequence> extends true ? $Result : { @@ -87,7 +89,7 @@ type HookReturn< export const createCore = < $HookSequence extends HookSequence = HookSequence, - $HookMap extends Record<$HookSequence[number], object> = Record<$HookSequence[number], object>, + $HookMap extends HookMap<$HookSequence> = HookMap<$HookSequence>, $Result = unknown, >(input: Omit, PrivateTypesSymbol>): Core<$HookSequence, $HookMap, $Result> => { return input as any @@ -95,7 +97,7 @@ export const createCore = < export type Core< $HookSequence extends HookSequence = HookSequence, - $HookMap extends Record<$HookSequence[number], object> = Record<$HookSequence[number], object>, + $HookMap extends HookMap<$HookSequence> = HookMap<$HookSequence>, $Result = unknown, > = { [PrivateTypesSymbol]: { From 993a1df065a7ebfe06a13f2fbf90e85d0d8c4a3b Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Wed, 22 May 2024 22:10:15 -0400 Subject: [PATCH 28/30] builder api --- src/layers/5_client/client.ts | 9 +- src/layers/5_core/core.ts | 2 +- src/lib/anyware/__.test-d.ts | 10 ++ ...ypoint.spec.ts => main.entrypoint.test.ts} | 0 .../anyware/{main.spec.ts => main.test.ts} | 0 src/lib/anyware/main.ts | 137 +++++++++++------- src/lib/anyware/specHelpers.ts | 26 ++-- src/lib/errors/ContextualError.ts | 4 +- 8 files changed, 116 insertions(+), 72 deletions(-) create mode 100644 src/lib/anyware/__.test-d.ts rename src/lib/anyware/{main.entrypoint.spec.ts => main.entrypoint.test.ts} (100%) rename src/lib/anyware/{main.spec.ts => main.test.ts} (100%) diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index fa15bceb..ad3ba416 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -1,5 +1,5 @@ import { type ExecutionResult, GraphQLSchema } from 'graphql' -import { Anyware } from '../../lib/anyware/__.js' +import type { Anyware } from '../../lib/anyware/__.js' import { Errors } from '../../lib/errors/__.js' import type { SomeExecutionResultWithoutErrors } from '../../lib/graphql.js' import { isOperationTypeName, operationTypeNameToRootTypeName, type RootTypeName } from '../../lib/graphql.js' @@ -11,7 +11,7 @@ import { readMaybeThunk } from '../1_Schema/core/helpers.js' import type { GlobalRegistry } from '../2_generator/globalRegistry.js' import type { DocumentObject, GraphQLObjectSelection } from '../3_SelectionSet/encode.js' import { Core } from '../5_core/__.js' -import type { HookInputEncode } from '../5_core/core.js' +import { type HookInputEncode } from '../5_core/core.js' import type { InterfaceRaw } from '../5_core/types.js' import type { ApplyInputDefaults, @@ -39,7 +39,6 @@ export type SelectionSetOrIndicator = 0 | 1 | boolean | object export type SelectionSetOrArgs = object export interface Context { - core: Anyware.Core extensions: Anyware.ExtensionInput[] config: Config } @@ -258,7 +257,6 @@ export const createInternal = ( } const context: Context = { - core: Core.create as any, // eslint-disable-line extensions: state.extensions, config: { returnMode, @@ -266,8 +264,7 @@ export const createInternal = ( } const run = async (context: Context, initialInput: HookInputEncode) => { - const result = await Anyware.runWithExtensions({ - core: context.core, + const result = await Core.anyware.run({ initialInput, extensions: context.extensions, }) as GraffleExecutionResult diff --git a/src/layers/5_core/core.ts b/src/layers/5_core/core.ts index b0fc3b98..c757339f 100644 --- a/src/layers/5_core/core.ts +++ b/src/layers/5_core/core.ts @@ -98,7 +98,7 @@ export type Hooks = { decode: HookInputDecode } -export const create = Anyware.createCore({ +export const anyware = Anyware.create({ hookNamesOrderedBySequence, hooks: { encode: ( diff --git a/src/lib/anyware/__.test-d.ts b/src/lib/anyware/__.test-d.ts new file mode 100644 index 00000000..4362ea8a --- /dev/null +++ b/src/lib/anyware/__.test-d.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ + +import { describe, expect, expectTypeOf, test, vi } from 'vitest' +import type { ContextualError } from '../errors/ContextualError.js' +import { Anyware } from './__.js' +import { anyware, oops, run, runWithOptions } from './specHelpers.js' + +test('types', () => { + // expectTypeOf(Anyware.create()) +}) diff --git a/src/lib/anyware/main.entrypoint.spec.ts b/src/lib/anyware/main.entrypoint.test.ts similarity index 100% rename from src/lib/anyware/main.entrypoint.spec.ts rename to src/lib/anyware/main.entrypoint.test.ts diff --git a/src/lib/anyware/main.spec.ts b/src/lib/anyware/main.test.ts similarity index 100% rename from src/lib/anyware/main.spec.ts rename to src/lib/anyware/main.test.ts diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts index 8b7b2106..6f69b5b3 100644 --- a/src/lib/anyware/main.ts +++ b/src/lib/anyware/main.ts @@ -61,7 +61,10 @@ type SomeHook = { input: object } -type HookMap<$HookSequence extends HookSequence> = Record<$HookSequence[number], object> +export type HookMap<$HookSequence extends HookSequence> = Record< + $HookSequence[number], + any /* object <- type error but more accurate */ +> type Hook< $HookSequence extends HookSequence, @@ -87,14 +90,6 @@ type HookReturn< > } -export const createCore = < - $HookSequence extends HookSequence = HookSequence, - $HookMap extends HookMap<$HookSequence> = HookMap<$HookSequence>, - $Result = unknown, ->(input: Omit, PrivateTypesSymbol>): Core<$HookSequence, $HookMap, $Result> => { - return input as any -} - export type Core< $HookSequence extends HookSequence = HookSequence, $HookMap extends HookMap<$HookSequence> = HookMap<$HookSequence>, @@ -268,9 +263,23 @@ const runHook = async <$HookName extends string>( return } +const ResultEnvelopeSymbol = Symbol(`resultEnvelope`) + +type ResultEnvelopeSymbol = typeof ResultEnvelopeSymbol + +type ResultEnvelop = { + [ResultEnvelopeSymbol]: ResultEnvelopeSymbol + result: T +} + +const createResultEnvelope = (result: T): ResultEnvelop => ({ + [ResultEnvelopeSymbol]: ResultEnvelopeSymbol, + result, +}) + const run = async ( { core, initialInput, initialHookStack }: { core: Core; initialInput: unknown; initialHookStack: Extension[] }, -) => { +): Promise | ContextualError> => { let currentInput = initialInput let currentHookStack = initialHookStack @@ -298,7 +307,7 @@ const run = async ( case `shortCircuited`: { debug(`signal: shortCircuited`) const { result } = signal - return result + return createResultEnvelope(result) } case `error`: { debug(`signal: error`) @@ -338,7 +347,7 @@ const run = async ( debug(`returning`) - return currentResult // last loop result + return createResultEnvelope(currentResult) // last loop result } const createPassthrough = (hookName: string) => async (hookEnvelope: SomeHookEnvelope) => { @@ -349,6 +358,71 @@ const createPassthrough = (hookName: string) => async (hookEnvelope: SomeHookEnv return await hook(hook.input) } +type Config = Required + +const resolveOptions = (options?: Options): Config => { + return { + entrypointSelectionMode: options?.entrypointSelectionMode ?? `required`, + } +} + +export type Options = { + /** + * @defaultValue `true` + */ + entrypointSelectionMode?: 'optional' | 'required' | 'off' +} + +export type Builder<$Core extends Core = Core> = { + core: $Core + run: ( + { initialInput, extensions, options }: { + initialInput: CoreInitialInput<$Core> + extensions: ExtensionInput[] + options?: Options + }, + ) => Promise<$Core[PrivateTypesSymbol]['result'] | Errors.ContextualError> +} + +export const create = < + $HookSequence extends HookSequence = HookSequence, + $HookMap extends HookMap<$HookSequence> = HookMap<$HookSequence>, + $Result = unknown, +>( + coreInput: Omit, PrivateTypesSymbol>, +): Builder> => { + type $Core = Core<$HookSequence, $HookMap, $Result> + + const core = coreInput as any as $Core + + const builder: Builder<$Core> = { + core, + run: async (input) => { + const { initialInput, extensions, options } = input + const initialHookStackAndErrors = extensions.map(extension => + toInternalExtension(core, resolveOptions(options), extension) + ) + const [initialHookStack, error] = partitionAndAggregateErrors(initialHookStackAndErrors) + + if (error) { + return error + } + + const result = await run({ + core, + initialInput, + // @ts-expect-error fixme + initialHookStack, + }) + if (result instanceof Error) return result + + return result.result as any + }, + } + + return builder +} + const toInternalExtension = (core: Core, config: Config, extension: ExtensionInput) => { const currentChunk = createDeferred() const body = createDeferred() @@ -414,42 +488,3 @@ const toInternalExtension = (core: Core, config: Config, extension: ExtensionInp throw casesExhausted(config.entrypointSelectionMode) } } - -type Config = Required -const resolveOptions = (options?: Options): Config => { - return { - entrypointSelectionMode: options?.entrypointSelectionMode ?? `required`, - } -} - -export type Options = { - /** - * @defaultValue `true` - */ - entrypointSelectionMode?: 'optional' | 'required' | 'off' -} - -export const runWithExtensions = async <$Core extends Core>( - { core, initialInput, extensions, options }: { - core: $Core - initialInput: CoreInitialInput<$Core> - extensions: ExtensionInput[] - options?: Options - }, -) => { - const initialHookStackAndErrors = extensions.map(extension => - toInternalExtension(core, resolveOptions(options), extension) - ) - const [initialHookStack, error] = partitionAndAggregateErrors(initialHookStackAndErrors) - - if (error) { - return error - } - - return await run({ - core, - initialInput, - // @ts-expect-error fixme - initialHookStack, - }) -} diff --git a/src/lib/anyware/specHelpers.ts b/src/lib/anyware/specHelpers.ts index 00efcd95..dab7f5b7 100644 --- a/src/lib/anyware/specHelpers.ts +++ b/src/lib/anyware/specHelpers.ts @@ -1,21 +1,20 @@ import type { Mock } from 'vitest' import { beforeEach, vi } from 'vitest' import { Anyware } from './__.js' -import { type Core, type ExtensionInput, type Options, runWithExtensions } from './main.js' +import { type ExtensionInput, type Options } from './main.js' export type Input = { value: string } -export type $Core = Core<['a', 'b']> & { +// export type $Core = Core<['a', 'b'],Anyware.HookMap<['a','b']>,Input> + +type $Core = ReturnType & { hooks: { a: Mock b: Mock } } -// @ts-expect-error -export let core: $Core = null - -export const createCore = (): $Core => { +export const createAnyware = () => { const a = vi.fn().mockImplementation((input: Input) => { return { value: input.value + `+a` } }) @@ -23,19 +22,24 @@ export const createCore = (): $Core => { return { value: input.value + `+b` } }) - return Anyware.createCore({ + return Anyware.create<['a', 'b'], Anyware.HookMap<['a', 'b']>, Input>({ hookNamesOrderedBySequence: [`a`, `b`], hooks: { a, b }, - }) as $Core + }) } +// @ts-expect-error +export let anyware: Anyware.Builder<$Core> = null +export let core: $Core + beforeEach(() => { - core = createCore() + // @ts-expect-error mock types not tracked by Anyware + anyware = createAnyware() + core = anyware.core }) export const runWithOptions = (options: Options = {}) => async (...extensions: ExtensionInput[]) => { - const result = await runWithExtensions({ - core, + const result = await anyware.run({ initialInput: { value: `initial` }, extensions, options, diff --git a/src/lib/errors/ContextualError.ts b/src/lib/errors/ContextualError.ts index 48896f7a..10bec1b3 100644 --- a/src/lib/errors/ContextualError.ts +++ b/src/lib/errors/ContextualError.ts @@ -8,7 +8,7 @@ import type { Cause, Context } from './types.js' export class ContextualError< $Name extends string = string, $Context extends Context = object, - $Cause extends Cause | undefined = undefined, + $Cause extends Cause | undefined = Cause | undefined, > extends Error { override name: $Name = `ContextualError` as $Name constructor( @@ -19,5 +19,3 @@ export class ContextualError< super(message, cause) } } - -export type SomeContextualError = ContextualError From 8273d16c66ad859b4142bd8db44984fd2d101780 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Wed, 22 May 2024 23:05:04 -0400 Subject: [PATCH 29/30] type tests --- src/layers/5_client/client.ts | 2 +- src/layers/5_core/core.ts | 2 ++ src/lib/anyware/__.test-d.ts | 45 +++++++++++++++++++++++++++++++---- src/lib/anyware/main.ts | 25 ++++++++++--------- 4 files changed, 55 insertions(+), 19 deletions(-) diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index ad3ba416..53f05d0c 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -64,7 +64,7 @@ export type Client<$Index extends Schema.Index | null, $Config extends Config> = : {} // eslint-disable-line ) & { - extend: (extension: Anyware.Extension2) => Client<$Index, $Config> + extend: (extension: Anyware.Extension2) => Client<$Index, $Config> } export type ClientTyped<$Index extends Schema.Index, $Config extends Config> = diff --git a/src/layers/5_core/core.ts b/src/layers/5_core/core.ts index c757339f..ca5e6a87 100644 --- a/src/layers/5_core/core.ts +++ b/src/layers/5_core/core.ts @@ -254,3 +254,5 @@ export const anyware = Anyware.create({ // 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... }) + +export type Core = (typeof anyware)['core'] diff --git a/src/lib/anyware/__.test-d.ts b/src/lib/anyware/__.test-d.ts index 4362ea8a..ef067c58 100644 --- a/src/lib/anyware/__.test-d.ts +++ b/src/lib/anyware/__.test-d.ts @@ -1,10 +1,45 @@ /* eslint-disable */ -import { describe, expect, expectTypeOf, test, vi } from 'vitest' -import type { ContextualError } from '../errors/ContextualError.js' +import { expectTypeOf, test } from 'vitest' +import { Result } from '../../../tests/_/schema/generated/SchemaRuntime.js' +import { ContextualError } from '../errors/ContextualError.js' +import { MaybePromise } from '../prelude.js' import { Anyware } from './__.js' -import { anyware, oops, run, runWithOptions } from './specHelpers.js' +import { ResultEnvelop, SomeHook } from './main.js' -test('types', () => { - // expectTypeOf(Anyware.create()) +type InputA = { valueA: string } +type InputB = { valueB: string } +type Result = { return: string } + +const create = Anyware.create<['a', 'b'], { a: InputA; b: InputB }, Result> + +test('create', () => { + expectTypeOf(create).toMatchTypeOf< + (input: { + hookNamesOrderedBySequence: ['a', 'b'] + hooks: { + a: (input: InputA) => InputB + b: (input: InputB) => Result + } + }) => any + >() +}) + +test('run', () => { + type run = ReturnType['run'] + + expectTypeOf().toMatchTypeOf< + (input: { + initialInput: InputA + options?: Anyware.Options + extensions: ((input: { + a: SomeHook< + (input: InputA) => MaybePromise<{ + b: SomeHook<(input: InputB) => MaybePromise> + }> + > + b: SomeHook<(input: InputB) => MaybePromise> + }) => Promise)[] + }) => Promise + >() }) diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts index 6f69b5b3..e5cbfeab 100644 --- a/src/lib/anyware/main.ts +++ b/src/lib/anyware/main.ts @@ -12,18 +12,17 @@ import { casesExhausted, createDeferred, debug, errorFromMaybeError } from '../p import { getEntrypoint } from './getEntrypoint.js' type HookSequence = readonly [string, ...string[]] + export type Extension2< - $HookSequence extends HookSequence, - $HookMap extends Record<$HookSequence[number], object> = Record<$HookSequence[number], object>, - $Result = unknown, + $Core extends Core = Core, > = ( hooks: ExtensionHooks< - $HookSequence, - $HookMap, - $Result + $Core[PrivateTypesSymbol]['hookSequence'], + $Core[PrivateTypesSymbol]['hookMap'], + $Core[PrivateTypesSymbol]['result'] >, ) => Promise< - | $Result + | $Core[PrivateTypesSymbol]['result'] | SomeHookEnvelope > @@ -49,7 +48,7 @@ type HookSymbol = typeof hookSymbol type SomeHookEnvelope = { [name: string]: SomeHook } -type SomeHook = { +export type SomeHook any = (input: any) => any> = 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 @@ -57,8 +56,7 @@ type SomeHook = { // TODO how do I make this input type object without breaking the final types in e.g. client.extend.test // Ask Pierre // (input: object): SomeHookEnvelope - (input: any): any - input: object + input: Parameters[0] } export type HookMap<$HookSequence extends HookSequence> = Record< @@ -129,7 +127,8 @@ type Extension = { currentChunk: Deferred } -export type ExtensionInput<$Input extends object = object> = (input: $Input) => MaybePromise +// export type ExtensionInput<$Input extends object = object> = (input: $Input) => MaybePromise +export type ExtensionInput<$Input extends object = any> = (input: $Input) => MaybePromise type HookDoneData = | { type: 'completed'; result: unknown; nextHookStack: Extension[] } @@ -267,7 +266,7 @@ const ResultEnvelopeSymbol = Symbol(`resultEnvelope`) type ResultEnvelopeSymbol = typeof ResultEnvelopeSymbol -type ResultEnvelop = { +export type ResultEnvelop = { [ResultEnvelopeSymbol]: ResultEnvelopeSymbol result: T } @@ -378,7 +377,7 @@ export type Builder<$Core extends Core = Core> = { run: ( { initialInput, extensions, options }: { initialInput: CoreInitialInput<$Core> - extensions: ExtensionInput[] + extensions: Extension2<$Core>[] options?: Options }, ) => Promise<$Core[PrivateTypesSymbol]['result'] | Errors.ContextualError> From 79040143eebf3949ea9beb3f663196795e45aabb Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Wed, 22 May 2024 23:43:52 -0400 Subject: [PATCH 30/30] done --- src/layers/5_client/client.ts | 14 ++++---------- src/lib/anyware/__.test-d.ts | 6 +++--- src/lib/anyware/main.test.ts | 12 +++++++++++- src/lib/anyware/main.ts | 13 ++++--------- src/lib/anyware/specHelpers.ts | 6 ++++-- src/lib/errors/ContextualAggregateError.ts | 7 +------ 6 files changed, 27 insertions(+), 31 deletions(-) diff --git a/src/layers/5_client/client.ts b/src/layers/5_client/client.ts index 53f05d0c..04e785d4 100644 --- a/src/layers/5_client/client.ts +++ b/src/layers/5_client/client.ts @@ -39,7 +39,7 @@ export type SelectionSetOrIndicator = 0 | 1 | boolean | object export type SelectionSetOrArgs = object export interface Context { - extensions: Anyware.ExtensionInput[] + extensions: Anyware.Extension2[] config: Config } @@ -64,7 +64,7 @@ export type Client<$Index extends Schema.Index | null, $Config extends Config> = : {} // eslint-disable-line ) & { - extend: (extension: Anyware.Extension2) => Client<$Index, $Config> + extend: (extension: Anyware.Extension2) => Client<$Index, $Config> } export type ClientTyped<$Index extends Schema.Index, $Config extends Config> = @@ -128,12 +128,6 @@ export type Input<$Schema extends GlobalRegistry.SchemaList> = { // elideInputKey: true, } & InputPrefilled<$Schema> -// type Create = < -// $Input extends Input, -// >( -// input: $Input, -// ) => $Input['schemaIndex'] - // dprint-ignore type Create = < $Input extends Input, @@ -156,7 +150,7 @@ export const create: Create = ( ) => createInternal(input_, { extensions: [] }) interface CreateState { - extensions: Anyware.ExtensionInput[] + extensions: Anyware.Extension2[] } export const createInternal = ( @@ -298,7 +292,7 @@ export const createInternal = ( const contextWithReturnModeSet = updateContextConfig(context, { returnMode: `graphqlSuccess` }) return await runRaw(contextWithReturnModeSet, rawInput) }, - extend: (extension: Anyware.ExtensionInput) => { + extend: (extension: Anyware.Extension2) => { // todo test that adding extensions returns a copy of client return createInternal(input, { extensions: [...state.extensions, extension] }) }, diff --git a/src/lib/anyware/__.test-d.ts b/src/lib/anyware/__.test-d.ts index ef067c58..c8ec9371 100644 --- a/src/lib/anyware/__.test-d.ts +++ b/src/lib/anyware/__.test-d.ts @@ -34,11 +34,11 @@ test('run', () => { options?: Anyware.Options extensions: ((input: { a: SomeHook< - (input: InputA) => MaybePromise<{ - b: SomeHook<(input: InputB) => MaybePromise> + (input?: InputA) => MaybePromise<{ + b: SomeHook<(input?: InputB) => MaybePromise> }> > - b: SomeHook<(input: InputB) => MaybePromise> + b: SomeHook<(input?: InputB) => MaybePromise> }) => Promise)[] }) => Promise >() diff --git a/src/lib/anyware/main.test.ts b/src/lib/anyware/main.test.ts index a0e5c0cf..6184a809 100644 --- a/src/lib/anyware/main.test.ts +++ b/src/lib/anyware/main.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, vi } from 'vitest' import type { ContextualError } from '../errors/ContextualError.js' -import { core, oops, run, runWithOptions } from './specHelpers.js' +import { core, initialInput, oops, run, runWithOptions } from './specHelpers.js' describe(`no extensions`, () => { test(`passthrough to implementation`, async () => { @@ -23,6 +23,16 @@ describe(`one extension`, () => { expect(core.hooks.a).toHaveBeenCalled() expect(core.hooks.b).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' }) + // todo why doesn't this work? + // expect(core.hooks.a).toHaveBeenCalled() + // expect(core.hooks.b).toHaveBeenCalled() + }) describe(`can short-circuit`, () => { test(`at start, return input`, async () => { expect( diff --git a/src/lib/anyware/main.ts b/src/lib/anyware/main.ts index e5cbfeab..7a0cb55d 100644 --- a/src/lib/anyware/main.ts +++ b/src/lib/anyware/main.ts @@ -1,9 +1,3 @@ -// todo allow hooks to have implementation overridden. -// E.g.: request((input) => {...}) -// todo allow hooks to have passthrough without explicit input passing -// E.g.: NOT await request(request.input) -// but instead simply: await request() - import { Errors } from '../errors/__.js' import { partitionAndAggregateErrors } from '../errors/ContextualAggregateError.js' import { ContextualError } from '../errors/ContextualError.js' @@ -69,7 +63,7 @@ type Hook< $HookMap extends HookMap<$HookSequence> = HookMap<$HookSequence>, $Result = unknown, $Name extends $HookSequence[number] = $HookSequence[number], -> = (<$$Input extends $HookMap[$Name]>(input: $$Input) => HookReturn<$HookSequence, $HookMap, $Result, $Name>) & { +> = (<$$Input extends $HookMap[$Name]>(input?: $$Input) => HookReturn<$HookSequence, $HookMap, $Result, $Name>) & { [hookSymbol]: HookSymbol input: $HookMap[$Name] } @@ -158,7 +152,8 @@ const runHook = async <$HookName extends string>( debug(`${name}: extension ${pausedExtension.name}`) // The extension is responsible for calling the next hook. - const hook = createHook(originalInput, (nextOriginalInput) => { + // If no input is passed that means use the original input. + const hook = createHook(originalInput, (maybeNextOriginalInput?: object) => { // Once called, the extension is paused again and we continue down the current hook stack. hookUsedDeferred.resolve(true) @@ -173,7 +168,7 @@ const runHook = async <$HookName extends string>( core, name, done, - originalInput: nextOriginalInput, + originalInput: maybeNextOriginalInput ?? originalInput, currentHookStack: nextCurrentHookStack, nextHookStack: nextNextHookStack, }) diff --git a/src/lib/anyware/specHelpers.ts b/src/lib/anyware/specHelpers.ts index dab7f5b7..5568f30d 100644 --- a/src/lib/anyware/specHelpers.ts +++ b/src/lib/anyware/specHelpers.ts @@ -4,6 +4,7 @@ import { Anyware } from './__.js' import { type ExtensionInput, type Options } from './main.js' export type Input = { value: string } +export const initialInput: Input = { value: `initial` } // export type $Core = Core<['a', 'b'],Anyware.HookMap<['a','b']>,Input> @@ -40,13 +41,14 @@ beforeEach(() => { export const runWithOptions = (options: Options = {}) => async (...extensions: ExtensionInput[]) => { const result = await anyware.run({ - initialInput: { value: `initial` }, + initialInput, + // @ts-expect-error fixme extensions, options, }) return result } -export const run = async (...extensions: ExtensionInput[]) => runWithOptions({})(...extensions) +export const run = async (...extensions: ExtensionInput[]) => runWithOptions({})(...extensions) export const oops = new Error(`oops`) diff --git a/src/lib/errors/ContextualAggregateError.ts b/src/lib/errors/ContextualAggregateError.ts index 43bb0778..78413250 100644 --- a/src/lib/errors/ContextualAggregateError.ts +++ b/src/lib/errors/ContextualAggregateError.ts @@ -1,7 +1,6 @@ import type { Include } from '../prelude.js' import { partitionErrors } from '../prelude.js' import { ContextualError } from './ContextualError.js' -import type { Cause } from './types.js' /** * Aggregation Error enhanced with a context object and types members. @@ -9,11 +8,7 @@ import type { Cause } from './types.js' * The library also exports a serializer you can use. */ export class ContextualAggregateError< - $Errors extends Error | ContextualError = ContextualError< - string, - object, - Cause | undefined - >, + $Errors extends Error | ContextualError = ContextualError, $Name extends string = `ContextualAggregateError`, $Context extends object = object, > extends ContextualError<$Name, $Context> {