Skip to content

Commit 203a033

Browse files
committed
upgrade executor to non-duplicating incremental delivery format
includes new options to allow for prior branching format
1 parent d401903 commit 203a033

33 files changed

Lines changed: 6647 additions & 1550 deletions

.changeset/fifty-bobcats-jog.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@graphql-tools/executor': major
3+
'@graphql-tools/utils': minor
4+
---
5+
6+
Upgrade to non-duplicating Incremental Delivery format
7+
8+
## Description
9+
10+
GraphQL Incremental Delivery is moving to a [new response format without duplication](https://github.com/graphql/defer-stream-wg/discussions/69).
11+
12+
This PR updates the executor within graphql-tools to avoid any duplication of fields as per the new format, a BREAKING CHANGE, released in graphql-js `v17.0.0-alpha.3`. The original version of incremental delivery was released in graphql-js `v17.0.0-alpha.2`.
13+
14+
The new format also includes new `pending` and `completed` entries where the `pending` entries assign `ids` to `defer` and `stream` entries, and the `completed` entries are sent as deferred fragments or streams complete. In the new format, the `path` and `label` are only sent along with the `id` within the `pending` entries. Also, incremental errors (i.e. errors that bubble up to a position that has already been sent) are sent within the `errors` field on `completed` entries, rather than as `incremental` entries with `data` or `items` set to `null`. The missing `path` and `label` fields and different mechanism for reporting incremental errors are also a BREAKING CHANGE.
15+
16+
Along with the new format, the GraphQL Working Group has also decided to disable incremental delivery support for subscriptions (1) to gather more information about use cases and (2) explore how to interleaving the incremental response streams generated from different source events into one overall subscription response stream. This is also a BREAKING CHANGE.
17+
18+
Library users can explicitly opt in to the older format by call `execute` with the following option:
19+
20+
```ts
21+
const result = await execute({
22+
...,
23+
incrementalPreset: 'v17.0.0-alpha.2',
24+
});
25+
```
26+
27+
The default value for `incrementalPreset` when omitted is `'v17.0.0-alpha.3'`, which enables the new behaviors described above. The new behaviors can also be disabled granularly as follows:
28+
29+
```ts
30+
const result = await execute({
31+
...,
32+
deferWithoutDuplication: false,
33+
useIncrementalNotifications: false,
34+
errorOnSubscriptionWithIncrementalDelivery: false,
35+
});
36+
```
37+
38+
Setting `deferWithoutDuplication` to `false` will re-enable deduplication according to the older format.
39+
Setting `useIncrementalNotifications` to `false` will (1) omit the `pending` entries, (2) send `path` and `label` on every `incremental` entry, (3) omit `completed` entries, and (4) send incremental errors within `incremental` entries along with a `data` or `items` field set to `null`.
40+
Setting `errorOnSubscriptionWithIncrementalDelivery` to `false` will re-enable the use of incremental delivery with subscriptions.
41+
```

packages/delegate/src/defaultMergedResolver.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {
55
responsePathAsArray,
66
SelectionSetNode,
77
} from 'graphql';
8-
import { getResponseKeyFromInfo, isPromise } from '@graphql-tools/utils';
9-
import { createDeferred, DelegationPlanLeftOver, getPlanLeftOverFromParent } from './leftOver.js';
8+
import { createDeferred, getResponseKeyFromInfo, isPromise } from '@graphql-tools/utils';
9+
import { DelegationPlanLeftOver, getPlanLeftOverFromParent } from './leftOver.js';
1010
import {
1111
getSubschema,
1212
getUnpathedErrors,

packages/delegate/src/leftOver.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,8 @@
11
import { FieldNode } from 'graphql';
2+
import { Deferred } from '@graphql-tools/utils';
23
import { Subschema } from './Subschema.js';
34
import { DelegationPlanBuilder, ExternalObject } from './types.js';
45

5-
export type Deferred<T = unknown> = PromiseWithResolvers<T>;
6-
7-
// TODO: Remove this after Node 22
8-
export function createDeferred<T>(): Deferred<T> {
9-
if (Promise.withResolvers) {
10-
return Promise.withResolvers();
11-
}
12-
let resolve: (value: T | PromiseLike<T>) => void;
13-
let reject: (error: unknown) => void;
14-
const promise = new Promise<T>((_resolve, _reject) => {
15-
resolve = _resolve;
16-
reject = _reject;
17-
});
18-
return { promise, resolve: resolve!, reject: reject! };
19-
}
20-
216
export interface DelegationPlanLeftOver {
227
unproxiableFieldNodes: Array<FieldNode>;
238
nonProxiableSubschemas: Array<Subschema>;

packages/executor/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"value-or-promise": "^1.0.12"
6363
},
6464
"devDependencies": {
65+
"@types/dlv": "^1.1.4",
6566
"cross-inspect": "1.0.1",
6667
"graphql": "^16.6.0"
6768
},
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* ES6 Map with additional `add` method to accumulate items.
3+
*/
4+
export class AccumulatorMap<K, T> extends Map<K, Array<T>> {
5+
get [Symbol.toStringTag]() {
6+
return 'AccumulatorMap';
7+
}
8+
9+
add(key: K, item: T): void {
10+
const group = this.get(key);
11+
if (group === undefined) {
12+
this.set(key, [item]);
13+
} else {
14+
group.push(item);
15+
}
16+
}
17+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { isPromise } from '@graphql-tools/utils';
2+
import type { MaybePromise } from '@graphql-tools/utils';
3+
4+
/**
5+
* A BoxedPromiseOrValue is a container for a value or promise where the value
6+
* will be updated when the promise resolves.
7+
*
8+
* A BoxedPromiseOrValue may only be used with promises whose possible
9+
* rejection has already been handled, otherwise this will lead to unhandled
10+
* promise rejections.
11+
*
12+
* @internal
13+
* */
14+
export class BoxedPromiseOrValue<T> {
15+
value: MaybePromise<T>;
16+
17+
constructor(value: MaybePromise<T>) {
18+
this.value = value;
19+
if (isPromise(value)) {
20+
value.then(resolved => {
21+
this.value = resolved;
22+
});
23+
}
24+
}
25+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { Path } from '@graphql-tools/utils';
2+
import { DeferUsage } from './collectFields.js';
3+
import { PendingExecutionGroup, StreamRecord, SuccessfulExecutionGroup } from './types.js';
4+
5+
export type DeliveryGroup = DeferredFragmentRecord | StreamRecord;
6+
7+
/** @internal */
8+
export class DeferredFragmentRecord {
9+
path: Path | undefined;
10+
label: string | undefined;
11+
id?: string | undefined;
12+
parentDeferUsage: DeferUsage | undefined;
13+
pendingExecutionGroups: Set<PendingExecutionGroup>;
14+
successfulExecutionGroups: Set<SuccessfulExecutionGroup>;
15+
children: Set<DeliveryGroup>;
16+
pending: boolean;
17+
fns: Array<() => void>;
18+
19+
constructor(
20+
path: Path | undefined,
21+
label: string | undefined,
22+
parentDeferUsage: DeferUsage | undefined,
23+
) {
24+
this.path = path;
25+
this.label = label;
26+
this.parentDeferUsage = parentDeferUsage;
27+
this.pendingExecutionGroups = new Set();
28+
this.successfulExecutionGroups = new Set();
29+
this.children = new Set();
30+
this.pending = false;
31+
this.fns = [];
32+
}
33+
34+
onPending(fn: () => void): void {
35+
this.fns.push(fn);
36+
}
37+
38+
setAsPending(): void {
39+
this.pending = true;
40+
for (const fn of this.fns) {
41+
fn();
42+
}
43+
}
44+
}
45+
46+
export function isDeferredFragmentRecord(
47+
deliveryGroup: DeliveryGroup,
48+
): deliveryGroup is DeferredFragmentRecord {
49+
return deliveryGroup instanceof DeferredFragmentRecord;
50+
}
51+
52+
/**
53+
* @internal
54+
*/
55+
export class DeferredFragmentFactory {
56+
private _rootDeferredFragments = new Map<DeferUsage, DeferredFragmentRecord>();
57+
58+
get(deferUsage: DeferUsage, path: Path | undefined): DeferredFragmentRecord {
59+
const deferUsagePath = this._pathAtDepth(path, deferUsage.depth);
60+
let deferredFragmentRecords: Map<DeferUsage, DeferredFragmentRecord> | undefined;
61+
if (deferUsagePath === undefined) {
62+
deferredFragmentRecords = this._rootDeferredFragments;
63+
} else {
64+
// A doubly nested Map<Path, Map<DeferUsage, DeferredFragmentRecord>>
65+
// could be used, but could leak memory in long running operations.
66+
// A WeakMap could be used instead. The below implementation is
67+
// WeakMap-Like, saving the Map on the Path object directly.
68+
// Alternatively, memory could be reclaimed manually, taking care to
69+
// also reclaim memory for nested DeferredFragmentRecords if the parent
70+
// is removed secondary to an error.
71+
deferredFragmentRecords = (
72+
deferUsagePath as unknown as {
73+
deferredFragmentRecords: Map<DeferUsage, DeferredFragmentRecord>;
74+
}
75+
).deferredFragmentRecords;
76+
if (deferredFragmentRecords === undefined) {
77+
deferredFragmentRecords = new Map();
78+
(
79+
deferUsagePath as unknown as {
80+
deferredFragmentRecords: Map<DeferUsage, DeferredFragmentRecord>;
81+
}
82+
).deferredFragmentRecords = deferredFragmentRecords;
83+
}
84+
}
85+
let deferredFragmentRecord = deferredFragmentRecords.get(deferUsage);
86+
if (deferredFragmentRecord === undefined) {
87+
const { label, parentDeferUsage } = deferUsage;
88+
deferredFragmentRecord = new DeferredFragmentRecord(deferUsagePath, label, parentDeferUsage);
89+
deferredFragmentRecords.set(deferUsage, deferredFragmentRecord);
90+
}
91+
return deferredFragmentRecord;
92+
}
93+
94+
private _pathAtDepth(path: Path | undefined, depth: number): Path | undefined {
95+
if (depth === 0) {
96+
return;
97+
}
98+
const stack: Array<Path> = [];
99+
let currentPath = path;
100+
while (currentPath !== undefined) {
101+
stack.unshift(currentPath);
102+
currentPath = currentPath.prev;
103+
}
104+
return stack[depth - 1];
105+
}
106+
}

0 commit comments

Comments
 (0)