diff --git a/.changeset/old-clocks-enter.md b/.changeset/old-clocks-enter.md new file mode 100644 index 0000000000..b5f2187a3d --- /dev/null +++ b/.changeset/old-clocks-enter.md @@ -0,0 +1,6 @@ +--- +"grafast": minor +"postgraphile": minor +--- + +Support branded dependency IDs diff --git a/grafast/grafast/examples/complexInputs.mts b/grafast/grafast/examples/complexInputs.mts index 2ba25fbbf6..f1d8cd92fc 100644 --- a/grafast/grafast/examples/complexInputs.mts +++ b/grafast/grafast/examples/complexInputs.mts @@ -62,7 +62,7 @@ setTimeout(async () => { // Everything below this line will be output in the docs at // http://build.graphile.org/graphile-build/next/all-hooks /******************************************************************************/ -import type { ExecutionDetails, ExecutionValue, Maybe } from "grafast"; +import type { DepId, ExecutionDetails, Maybe } from "grafast"; import { makeGrafastSchema, Modifier, Step } from "grafast"; interface Filterable { @@ -117,8 +117,8 @@ class SearchRequestStep extends Step<{ sql: string; patchJSON: string; }> { - private readonly patchDepId: number; - private readonly applyDepIds: number[] = []; + private readonly patchDepId: DepId>; + private readonly applyDepIds: DepId>[] = []; constructor($patch: Step) { super(); @@ -130,9 +130,7 @@ class SearchRequestStep extends Step<{ } execute(details: ExecutionDetails) { - const patchDep = details.values[ - this.patchDepId - ] as ExecutionValue; + const patchDep = details.values.at(this.patchDepId); const clauses: string[] = [ // Put any initial clauses here @@ -140,7 +138,7 @@ class SearchRequestStep extends Step<{ // Apply the callbacks const applyCallbacks = this.applyDepIds - .flatMap((id) => details.values[id].unaryValue() as FilterCallbacks) + .flatMap((id) => details.values.at(id).unaryValue()) .filter((cb) => cb != null); const filterable: Filterable = { addClause: (clause) => void clauses.push(clause), diff --git a/grafast/grafast/src/engine/executeBucket.ts b/grafast/grafast/src/engine/executeBucket.ts index 0cb89737fd..6ab902802c 100644 --- a/grafast/grafast/src/engine/executeBucket.ts +++ b/grafast/grafast/src/engine/executeBucket.ts @@ -24,6 +24,7 @@ import type { ExecutionExtra, ExecutionResults, ExecutionValue, + ExecutionValues, ForcedValues, GrafastInternalResultsOrStream, IndexForEach, @@ -830,7 +831,7 @@ export function executeBucket( indexMap: makeIndexMap(count), indexForEach: makeIndexForEach(count), count, - values, + values: values as ExecutionValues, extra, stream: evaluateStream(bucket, step, distributorOptions), }; diff --git a/grafast/grafast/src/index.ts b/grafast/grafast/src/index.ts index 11aac9113c..9b9d2949af 100644 --- a/grafast/grafast/src/index.ts +++ b/grafast/grafast/src/index.ts @@ -135,7 +135,12 @@ import { getGrafastMiddleware } from "./middleware.ts"; import type { Multistep, UnwrapMultistep } from "./multistep.ts"; import { multistep } from "./multistep.ts"; import { getNullableInputTypeAtPath } from "./operationPlan-input.ts"; -import type { ListCapableStep, ListLikeStep, ObjectLikeStep } from "./step.ts"; +import type { + DepId, + ListCapableStep, + ListLikeStep, + ObjectLikeStep, +} from "./step.ts"; import { assertExecutableStep, assertListCapableStep, @@ -313,6 +318,7 @@ export type { ConnectionOptimizedStep, DataFromObjectSteps, DataFromStep, + DepId, DeprecatedInputObjectPlan, DeprecatedObjectPlan, EnumPlan, diff --git a/grafast/grafast/src/interfaces.ts b/grafast/grafast/src/interfaces.ts index 16e2d6b632..ea48a34c5a 100644 --- a/grafast/grafast/src/interfaces.ts +++ b/grafast/grafast/src/interfaces.ts @@ -41,7 +41,7 @@ import type { LayerPlanReasonListItemStream } from "./engine/LayerPlan.ts"; import type { OperationPlan } from "./engine/OperationPlan.ts"; import type { FlaggedValue, SafeError } from "./error.ts"; import type { GrafastOperationOptions } from "./prepare.ts"; -import type { Step } from "./step.ts"; +import type { DepId, Step, StepData } from "./step.ts"; import type { __InputDefaultStep } from "./steps/__inputDefault.ts"; import type { __InputDynamicScalarStep } from "./steps/__inputDynamicScalar.ts"; import type { ApplyableStep } from "./steps/applyInput.ts"; @@ -720,6 +720,11 @@ export interface ExecutionDetailsStream { initialCount: number; } +type TupleIndicies = K extends K + ? K extends `${infer N extends number}` + ? N + : never + : never; export interface ExecutionDetails< TDeps extends readonly [...any[]] = readonly [...any[]], > { @@ -727,12 +732,7 @@ export interface ExecutionDetails< count: number; /** An "execution value" for each dependency of the step */ - values: { - [DepIdx in keyof TDeps]: ExecutionValue; - } & { - length: TDeps["length"]; - map: ReadonlyArray>["map"]; - }; + values: ExecutionValues; /** Helper; makes array from `callback(batchIndex)` for each `0 <= batchIndex < count` */ indexMap: IndexMap; @@ -751,6 +751,19 @@ export interface ExecutionDetails< /** Currently experimental, use it at your own risk (and see the source for docs) */ extra: ExecutionExtra; } +export type ExecutionValues< + TDeps extends readonly [...any[]] = readonly [...any[]], +> = { + [DepIdx in TupleIndicies]: ExecutionValue; +} & Omit[], "at"> & { + at>( + id: TDepId, + ): ExecutionValue< + NonNullable extends Step + ? StepData> + : TDeps[TDepId] + >; + }; export interface LocationDetails { node: ASTNode | readonly ASTNode[]; diff --git a/grafast/grafast/src/step.ts b/grafast/grafast/src/step.ts index 2acbb363bb..f1d7657264 100644 --- a/grafast/grafast/src/step.ts +++ b/grafast/grafast/src/step.ts @@ -79,6 +79,10 @@ function reallyAssertFinalized(plan: Step): void { // Optimise this away in production. export const assertFinalized = !isDev ? noop : reallyAssertFinalized; +export type DepId = number & { __step?: TStep }; +export type StepData = + TStep extends Step ? Data : never; + /** * Executable plans are the plans associated with leaves on the GraphQL tree, * they must be able to execute to return values. @@ -448,7 +452,7 @@ export /* abstract */ class Step { } protected getDepOptions( - depId: number, + depId: DepId, ): DependencyOptions { this._assertAccessAllowed(depId); return this._getDepOptions(depId); @@ -480,14 +484,14 @@ export /* abstract */ class Step { } protected getDep( - _depId: number, + _depId: DepId, ): TStep | __FlagStep; protected getDep( - _depId: number, + _depId: DepId, throwOnFlagged: true, ): TStep; protected getDep( - _depId: number, + _depId: DepId, _throwOnFlagged = false, ): TStep | __FlagStep { // This gets replaced when `__FlagStep` is loaded. Were we on ESM we could @@ -497,14 +501,14 @@ export /* abstract */ class Step { } protected maybeGetDep( - depId: number | null | undefined, + depId: DepId | null | undefined, ): TStep | __FlagStep | null; protected maybeGetDep( - depId: number | null | undefined, + depId: DepId | null | undefined, throwOnFlagged: true, ): TStep | null; protected maybeGetDep( - depId: number | null | undefined, + depId: DepId | null | undefined, throwOnFlagged = false, ): TStep | __FlagStep | null { return depId == null @@ -515,7 +519,7 @@ export /* abstract */ class Step { } protected getDepOrConstant( - _depId: number | null, + _depId: DepId> | null, _fallback: TData, ): Step { // This gets replaced when `constant` is loaded. Were we on ESM we could @@ -686,8 +690,10 @@ ${printDeps(step, 1)} } return this.operationPlan.stepTracker.addStepDependency(this, options); } - protected addDependency(stepOrOptions: Step | AddDependencyOptions): number { - const options: AddDependencyOptions = { + protected addDependency( + stepOrOptions: TStep | AddDependencyOptions, + ): DepId { + const options: AddDependencyOptions = { dataOnly: false, skipDeduplication: false, ...(stepOrOptions instanceof Step @@ -697,9 +703,9 @@ ${printDeps(step, 1)} assertStep(options.step, () => `${this}.addDependency`); return this._addDependency(options); } - protected addDataDependency( - stepOrOptions: Step | AddDependencyOptions, - ): number { + protected addDataDependency( + stepOrOptions: TStep | AddDependencyOptions, + ): DepId { const options = stepOrOptions instanceof Step ? { step: stepOrOptions } : stepOrOptions; assertStep(options.step, () => `${this}.addDataDependency`); @@ -710,9 +716,9 @@ ${printDeps(step, 1)} }); } // Currently identical to addDependency - protected addStrongDependency( - stepOrOptions: Step | AddDependencyOptions, - ): number { + protected addStrongDependency( + stepOrOptions: TStep | AddDependencyOptions, + ): DepId { const options = { dataOnly: false, skipDeduplication: false, @@ -730,10 +736,10 @@ ${printDeps(step, 1)} * `isBatch = false` so you can use the `values[index].value` property * directly. */ - protected addUnaryDependency( - stepOrOptions: Step | AddUnaryDependencyOptions, - ): number { - const options: AddUnaryDependencyOptions = + protected addUnaryDependency( + stepOrOptions: TStep | AddUnaryDependencyOptions, + ): DepId { + const options: AddUnaryDependencyOptions = stepOrOptions instanceof Step ? { step: stepOrOptions } : stepOrOptions; assertStep(options.step, () => `${this}.addUnaryDependency`); if (options.step.layerPlan.id > this.layerPlan.id) { diff --git a/grafast/grafast/src/steps/__flag.ts b/grafast/grafast/src/steps/__flag.ts index 825648373c..b7600dff4b 100644 --- a/grafast/grafast/src/steps/__flag.ts +++ b/grafast/grafast/src/steps/__flag.ts @@ -98,7 +98,7 @@ export class __FlagStep extends Step> { }; isSyncAndSafe = false; - private ifDep: number | null = null; + private ifDep; private forbiddenFlags: ExecutionEntryFlags; private onRejectReturnValue: FlaggedValue | FlaggedValue; private valueForInhibited: ResolvedTrapValue; @@ -146,8 +146,7 @@ export class __FlagStep extends Step> { const rej = this.onRejectReturnValue ? trim(String(this.onRejectReturnValue)) : inspect(this.onRejectReturnValue); - const $if = - this.ifDep !== null ? this.getDepOptions(this.ifDep).step : null; + const $if = this.ifDep != null ? this.getDepOptions(this.ifDep).step : null; return `${this.dependencies[0].id}, ${ $if ? `if(${$if.id}), ` : `` }${digestAcceptFlags(acceptFlags)}, onReject: ${rej}`; @@ -229,8 +228,7 @@ export class __FlagStep extends Step> { details: ExecutionDetails<[data: DataFromStep, cond?: boolean]>, ): any { const dataEv = details.values[0]!; - const condEv = - this.ifDep === null ? null : details.values[this.ifDep as 1]!; + const condEv = this.ifDep == null ? null : details.values.at(this.ifDep); const { forbiddenFlags: thisForbiddenFlags, onRejectReturnValue, diff --git a/grafast/grafast/src/steps/listTransform.ts b/grafast/grafast/src/steps/listTransform.ts index 01066e23a2..5c0e164ed4 100644 --- a/grafast/grafast/src/steps/listTransform.ts +++ b/grafast/grafast/src/steps/listTransform.ts @@ -253,7 +253,7 @@ export class __ListTransformStep< ); } - const listStepValue = values[this.listStepDepId]; + const listStepValue = values[this.listStepDepId as 0]; if (itemStep._isUnary) { const list = listStepValue.unaryValue(); diff --git a/grafast/grafast/src/steps/listen.ts b/grafast/grafast/src/steps/listen.ts index 4ffa0a0a3d..7766d4b073 100644 --- a/grafast/grafast/src/steps/listen.ts +++ b/grafast/grafast/src/steps/listen.ts @@ -68,7 +68,7 @@ export class ListenStep< values, stream, }: ExecutionDetails< - readonly [GrafastSubscriber, TTopic] + readonly [GrafastSubscriber, TTopic, TTopics[TTopic] | undefined] >): GrafastResultStreamList { if (!stream) { throw new Error("ListenStep must be streamed, never merely executed"); @@ -76,7 +76,7 @@ export class ListenStep< const pubsubValue = values[this.pubsubDep as 0]; const topicValue = values[this.topicDep as 1]; const initialEventValue = - this.initialEventDep !== null ? values[this.initialEventDep] : null; + this.initialEventDep !== null ? values[this.initialEventDep as 2] : null; return indexMap((i) => { const pubsub = pubsubValue.at(i); if (!pubsub) { diff --git a/grafast/grafast/src/steps/proxy.ts b/grafast/grafast/src/steps/proxy.ts index 268dfc5581..33a89a0b7b 100644 --- a/grafast/grafast/src/steps/proxy.ts +++ b/grafast/grafast/src/steps/proxy.ts @@ -1,10 +1,11 @@ import { $$proxy } from "../constants.ts"; import type { + AddDependencyOptions, ExecutionDetails, GrafastResultsList, UnbatchedExecutionExtra, } from "../interfaces.ts"; -import type { Step } from "../step.ts"; +import type { DepId, Step } from "../step.ts"; import { UnbatchedStep } from "../step.ts"; import { arrayOfLength } from "../utils.ts"; @@ -30,7 +31,9 @@ export class ProxyStep extends UnbatchedStep { return $dep.toString(); } // Publicly expose this - public addDependency(step: Step): number { + public addDependency( + step: TStep | AddDependencyOptions, + ): DepId { return super.addDependency(step); } execute({ @@ -121,6 +124,7 @@ export function proxy>( $actualDep: Step = $step, ): TStep & { addDependency(step: Step): number } { const $proxy = new ProxyStep($step, $actualDep); + // @ts-ignore const proxy = new Proxy($proxy, makeProxyHandler($step)) as any; // Lie. $proxy[$$proxy] = proxy; return proxy; diff --git a/grafast/grafast/src/steps/reverse.ts b/grafast/grafast/src/steps/reverse.ts index 449f4b0f6f..4358ea5712 100644 --- a/grafast/grafast/src/steps/reverse.ts +++ b/grafast/grafast/src/steps/reverse.ts @@ -34,15 +34,15 @@ export class ReverseStep extends UnbatchedStep { isSyncAndSafe = true; allowMultipleOptimizations = true; + private depId; + constructor(plan: Step) { super(); - this.addDependency(plan); + this.depId = this.addDependency(plan); } - execute({ - indexMap, - values: [values0], - }: ExecutionDetails<[TData[]]>): GrafastResultsList { + execute({ indexMap, values }: ExecutionDetails): GrafastResultsList { + const values0 = values.at(this.depId); return indexMap((i) => { const arr = values0.at(i); return arr == null ? arr : reverseArray(arr); diff --git a/grafast/website/grafast/index.mdx b/grafast/website/grafast/index.mdx index d9aed33ab4..4e982abd89 100644 --- a/grafast/website/grafast/index.mdx +++ b/grafast/website/grafast/index.mdx @@ -85,7 +85,7 @@ The GraphQL specification [notes](https://spec.graphql.org/draft/#sec-Conforming-Algorithms): > _Conformance requirements [...] can be fulfilled [...] in any way as long as -> the perceived result is equivalent._ +> the perceived result is equivalent._ > ─ https://spec.graphql.org/draft/#sec-Conforming-Algorithms Gra*fast* has been written very carefully by a [GraphQL Technical Steering @@ -336,9 +336,10 @@ once all of its dependencies are ready, continuing until all steps are complete. At planning time a step can add a dependency on another step via `const depId = -this.addDependency($otherStep);`. This `depId` is the index in the **values -tuple** that the step can use at execution time to retrieve the associated -values. +this.addDependency($otherStep);`. This `depId` is a branded dependency ID: at +runtime it is the index in the **values tuple**, and in TypeScript it carries +the dependency step type so the step can use `values.at(depId)` at execution +time to retrieve the associated values. When a step executes, its `execute` method is passed the **execution details** which includes: @@ -393,7 +394,7 @@ export class RecordsByColumnStep extends Step { super(); this.tableName = tableName; this.columnName = columnName; - this.columnValueDepIdx = this.addDependency($columnValue); + this.columnValueDepId = this.addDependency($columnValue); } setFirst($first) { @@ -402,15 +403,15 @@ export class RecordsByColumnStep extends Step { async execute({ indexMap, values }) { // Retrieve the values for the `$columnValue` dependency - const columnValueDep = values[this.columnValueDepIdx]; + const columnValueDep = values.at(this.columnValueDepId); // We may or may not have added a `$first` limit: const firstDep = - this.firstDepId !== undefined ? values[this.firstDepId] : undefined; + this.firstDepId !== undefined ? values.at(this.firstDepId) : undefined; // firstDep, if it exists, is definitely a unary dep (!firstDep.isBatch), so // we can retrieve its value directly: - const first = firstDep ? parseInt(firstDep.value, 10) : null; + const first = firstDep ? parseInt(firstDep.unaryValue(), 10) : null; // Create a `LIMIT` clause in our SQL if the user specified a `$first` limit: const limitSQL = Number.isFinite(first) ? `limit ${first}` : ``; diff --git a/grafast/website/grafast/plan-resolvers/complex-inputs.md b/grafast/website/grafast/plan-resolvers/complex-inputs.md index ae06c81128..6614173111 100644 --- a/grafast/website/grafast/plan-resolvers/complex-inputs.md +++ b/grafast/website/grafast/plan-resolvers/complex-inputs.md @@ -241,8 +241,10 @@ may apply to the same step, you should expect multiple calls to `.apply()` and thus store all dependency IDs in an array: ```ts +import { Step, type DepId } from "grafast"; + class MyRequestStep extends Step { - applyDepIds: number[] = []; + applyDepIds: DepId void>>[] = []; apply($cb: Step<(parent: any) => void>) { this.applyDepIds.push(this.addUnaryDependency($cb)); @@ -273,7 +275,7 @@ class MyRequestStep extends Step { // Apply the changes from all the `.apply($cb)` calls for (const applyDepId of this.applyDepIds) { - const applyCallback = values[applyDepId].unaryValue(); + const applyCallback = values.at(applyDepId).unaryValue(); applyCallback(builder); } diff --git a/grafast/website/grafast/step-classes.mdx b/grafast/website/grafast/step-classes.mdx index 5902affd69..8ca349e668 100644 --- a/grafast/website/grafast/step-classes.mdx +++ b/grafast/website/grafast/step-classes.mdx @@ -131,8 +131,10 @@ but Gra*fast* steps are much more powerful thanks to the additional dependencies and lifecycle methods. When the step class adds a dependency (with `this.addDependency($step)` or -similar) a dependency index, `depIndex`, is returned. Dependency indexes start -at 0 and increase monotonically (a dependency can never be removed[^1]). +similar) a dependency ID, `depId`, is returned. Dependency IDs are numbers at +runtime and start at 0, increasing monotonically (a dependency can never be +removed[^1]). In TypeScript they are branded as `DepId`, which preserves +the dependency step type for helpers such as `details.values.at(depId)`. [^1]: To remove a dependency, you must instead replace the step with a copy that @@ -155,9 +157,10 @@ of the execute method. `details.values` is also critical: an ordered array of execution values, one for each dependency. You can retrieve the execution value for a given dependency via -its `depIndex` as `const depEv = details.values[depIndex]`. Once you have an -execution value, you can retrieve the value for a given index in the batch via -`const value = depEv.at(batchIndex)`. +its `depId` as `const depEv = details.values.at(depId)`. Tuple destructuring or +`details.values[depIndex]` is also fine when it is more convenient. Once you +have an execution value, you can retrieve the value for a given index in the +batch via `const value = depEv.at(batchIndex)`. Gra*fast* tracks which steps will always represent exactly one value (e.g. the GraphQL context, input values passed as field arguments, constants, etc), and @@ -180,9 +183,9 @@ top of the execute method: class MyStep extends Step { constructor($a, $b, $c) { super(); - this.aDepIndex = this.addUnaryDependency($a); - this.bDepIndex = this.addDependency($b); - this.cDepIndex = this.addDependency($c); + this.addUnaryDependency($a); + this.addDependency($b); + this.addDependency($c); } execute(details) { const { values } = details; @@ -243,23 +246,25 @@ Here's a hypothetical example, concentrate on the `execute()` method and see how the steps outlined above play out: ```ts +import { Step, type DepId } from "grafast"; import { languageService } from "./services/language"; -class TranslationStep extends Step { - langDepIndex: number; - textDepIndex: number; +class TranslationStep extends Step { + langDepId: DepId>; + textDepId: DepId>; + constructor($language: Step, $text: Step) { super(); // Add our dependencies - this.langDepIndex = this.addDependency($language); - this.textDepIndex = this.addDependency($text); + this.langDepId = this.addDependency($language); + this.textDepId = this.addDependency($text); } - execute(details) { + async execute(details) { // highlight-start // 1. Extract and identify the execution value for each dependency: // highlight-end - const langEv = details.values[this.langDepIndex]; - const textEv = details.values[this.textDepIndex]; + const langEv = details.values.at(this.langDepId); + const textEv = details.values.at(this.textDepId); // highlight-start // 2. Map over the indices to prepare the input for our translation API: @@ -278,7 +283,7 @@ class TranslationStep extends Step { // highlight-start // 4. Finally, return results that correlate with the inputs: // highlight-end - return indexMap((batchIndex) => { + return details.indexMap((batchIndex) => { const language = langEv.at(batchIndex); const sourceText = textEv.at(batchIndex); const match = translations.find( @@ -736,8 +741,10 @@ as part of `Step`. When your step requires another step's value in order to execute (which is the case for the majority of steps!) it must add a dependency via the -`this.addDependency($otherStep)` method. This method will return a number, -which is the index in the `execute` values tuple that represents this step. +`this.addDependency($otherStep)` method. This method will return a `DepId` +(a branded number), which is the index in the `execute` values tuple that +represents the dependency. TypeScript uses the brand to infer the dependency's +data type when you later call `details.values.at(depId)`. It's common to do this in the constructor, but it can be done at other stages too, for example during the optimize phase a step's descendant might ask it to @@ -750,8 +757,8 @@ class added two dependencies: class AddStep extends Step { constructor($a, $b) { super(); - this.addDependency($a); // Returns 0 - this.addDependency($b); // Returns 1 + this.addDependency($a); // Returns 0 as a DepId + this.addDependency($b); // Returns 1 as a DepId } } ``` @@ -805,9 +812,10 @@ input values are not). ### getDep -Pass in the number of the dependency (`0` for the first dependency, `1` for the -second, and so on) and Gra*fast* will return the corresponding step. This should -only be used before or during the `optimize` phase. +Pass in the dependency ID returned from `addDependency`, or a literal dependency +index (`0` for the first dependency, `1` for the second, and so on), and +Gra*fast* will return the corresponding step. This should only be used before +or during the `optimize` phase. For example in the `AddStep` example above we might have: