@makerx/graphql-core 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -370,8 +370,26 @@ This library includes a `subscriptions` module to provide simple setup using the
370
370
 
371
371
  Wraps an `AsyncIterableIterator` (e.g. one returned by a pubsub `asyncIterableIterator(...)` call) with two optional behaviours:
372
372
 
373
- - `initialPayload` — a single event or array of events yielded before the wrapped iterator's events. Useful when a client subscribes after a state change has already happened, so they don't miss the current state while waiting for the next event.
374
- - `eventIsFinal` — a predicate that ends iteration eagerly when it returns `true` for an event. The final event is still yielded, then `wrapped.return()` is called to release resources. Only applied to events from the wrapped iterator, not to entries in `initialPayload`.
373
+ - `initialPayload` — a single event, an array of events, or a **factory** (`() => event | event[] | undefined | Promise<…>`) yielded before the wrapped iterator's events. Useful when a client subscribes after a state change has already happened, so they don't miss the current state while waiting for the next event. A factory may return `undefined` (e.g. a `findOne` that yields nothing) to mean "no snapshot", in which case iteration proceeds straight to the wrapped stream.
374
+ - `eventIsFinal` — a predicate that ends iteration eagerly when it returns `true` for an event. The triggering event is still yielded, then `wrapped.return()` is called to release resources. It is checked against every event from the wrapped iterator **and** against the _final_ entry of `initialPayload` (earlier entries are always yielded); if that final entry is terminal, iteration ends after the payload drains — in the value form without ever pulling the wrapped iterator.
375
+
376
+ #### Prefer the factory form for "snapshot then stream"
377
+
378
+ pubsub iterators (e.g. `graphql-subscriptions` / `graphql-redis-subscriptions`) subscribe to their channel **lazily, on the first `next()`**. If you read your snapshot (the initial payload) _before_ the iterator is pulled, the channel isn't live yet — any event published between the snapshot read and the subscription going live is **lost** _and_ missing from the snapshot, so the client can be left showing stale state until a manual refetch.
379
+
380
+ Passing `initialPayload` as a **factory** closes that window: the helper eagerly pulls the wrapped iterator once (establishing the subscription) _before_ invoking the factory to read the snapshot. Events fired during the snapshot read are then buffered by the wrapped iterator and delivered immediately after the snapshot, instead of being dropped.
381
+
382
+ ```ts
383
+ return wrapSubscriptionIterator({
384
+ iterator: pubsub().asyncIterableIterator(`topic.${id}`),
385
+ // factory runs AFTER the subscription is kicked, so an event fired during the read isn't lost
386
+ initialPayload: () => repo.find({ where: { id } }),
387
+ })
388
+ ```
389
+
390
+ > The plain value form (a single event or array) is unchanged and still subscribes lazily — fine when there is no read between subscribe and snapshot (e.g. a cached value already in hand, as below).
391
+ >
392
+ > Detection is via `typeof initialPayload === 'function'`. If your event type is itself callable, wrap a value-form payload in an array (`[value]`) to disambiguate it from a factory.
375
393
 
376
394
  Typical use is in a subscription resolver that needs to bridge a "current state" (cache or other persisted state lookup) with a live event stream, and close the subscription once a terminal event arrives:
377
395
 
@@ -405,6 +423,104 @@ The returned value is both an `AsyncIterator` and `AsyncIterable`, so it can be
405
423
  extractAnonymousOperationName('subscription { onUpdate { id } }') // 'subscription onUpdate'
406
424
  ```
407
425
 
426
+ ## GraphQL test helpers
427
+
428
+ The `@makerx/graphql-core/testing` submodule provides helpers for running GraphQL operations directly against a schema, without spinning up an HTTP or websocket server.
429
+
430
+ Bypassing the transport layer supports complete control over JWT payloads and other operation context inputs required to set up complex test scenarios.
431
+
432
+ This complements the `@makerx/graphql-apollo-server/testing` module, which exports `buildExecuteOperation` for running queries and mutations directly against an `ApolloServer` instance. Subscriptions normally flow over websockets via [graphql-ws](https://the-guild.dev/graphql/ws), so they need their own helper that calls `graphql.subscribe` directly against the schema.
433
+
434
+ ### buildSubscribeOperation
435
+
436
+ `buildSubscribeOperation` accepts a `GraphQLSchema` (or async schema factory) and a context creation function and returns a `subscribeOperation` function which:
437
+
438
+ - is strongly typed to the GraphQL context
439
+ - accepts `TypedDocumentNode` subscriptions to provide strong operation typing
440
+ - forwards any additional arguments to the supplied context creation function
441
+ - returns the subscription's `AsyncIterableIterator<ExecutionResult<TData>>` so tests can consume events via `for await` or manual `next()` calls
442
+ - throws if the subscription fails to produce an iterator (e.g. a validation error), surfacing the underlying errors
443
+
444
+ The shape of the context creation function — and the JWT/user factories used to drive it — depends on your GraphQL implementation, so the example below illustrates one common pattern using [Vitest test contexts](https://vitest.dev/guide/test-context), matching the [`buildExecuteOperation`](https://github.com/MakerXStudio/graphql-apollo-server#apollo-server-test-helpers) examples for queries and mutations.
445
+
446
+ #### Vitest GraphQL context example
447
+
448
+ Note: extend auth context (e.g. `buildJwt`, `buildUserJwt`) and other context as required — see the [graphql-apollo-server test helper docs](https://github.com/MakerXStudio/graphql-apollo-server#vitest-auth-context-example) for a complete auth fixture example.
449
+
450
+ `test/graphql.ts`
451
+
452
+ ```ts
453
+ import { buildSubscribeOperation } from '@makerx/graphql-core/testing'
454
+ import { test as baseTest } from './auth'
455
+
456
+ const createContext = async (jwtPayload?: JwtPayload): Promise<GraphQLContext> => {
457
+ const user = await findUpdateOrCreateUser(jwtPayload, randomUUID())
458
+ const baseContext: BaseContext = { user, logger, requestInfo, started: Date.now() }
459
+ const extraContext = await augmentContext(baseContext)
460
+ return { ...baseContext, ...extraContext }
461
+ }
462
+
463
+ export const test = baseTest
464
+ .extend('schema', { scope: 'worker' }, async ({}) => createSchema())
465
+ .extend('backgroundJobs', { scope: 'worker' }, backgroundJobsFixture)
466
+ .extend('executeOperation', { scope: 'worker' }, async ({ schema }, { onCleanup }) => {
467
+ const server = new ApolloServer<GraphQLContext>({ schema })
468
+ onCleanup(() => server.stop())
469
+ return buildExecuteOperation(server, createContext)
470
+ })
471
+ .extend('subscribeOperation', { scope: 'worker' }, async ({ schema }) => {
472
+ return buildSubscribeOperation(schema, createContext)
473
+ })
474
+ ```
475
+
476
+ #### job-status.subscription.test.ts
477
+
478
+ The test below uses the `graphql` template-literal tag from [GraphQL-Codegen](https://the-guild.dev/graphql/codegen/docs/getting-started) to produce strongly typed operations.
479
+
480
+ ```ts
481
+ import { describe, expect } from 'vitest'
482
+ import { graphql } from './gql'
483
+ import { test } from '../../../test/graphql'
484
+
485
+ const jobStatusSubscription = graphql(`
486
+ subscription JobStatus($jobId: ID!) {
487
+ jobStatus(jobId: $jobId) {
488
+ jobId
489
+ status
490
+ }
491
+ }
492
+ `)
493
+
494
+ describe('jobStatus subscription operation', () => {
495
+ test('anonymous subscribes fail', async ({ subscribeOperation }) => {
496
+ await expect(subscribeOperation({ subscription: jobStatusSubscription, variables: { jobId: 'job-1' } })).rejects.toThrow(
497
+ /Not authenticated/,
498
+ )
499
+ })
500
+
501
+ test('authenticated subscribers receive events until terminal status', async ({ subscribeOperation, buildUserJwt }) => {
502
+ const jwt = buildUserJwt()
503
+ const jobId = randomUUID()
504
+
505
+ const iterator = await subscribeOperation({ subscription: jobStatusSubscription, variables: { jobId } }, jwt)
506
+
507
+ // app-specific: trigger the events your subscription resolves over
508
+ // await addJob('demo', { demoPayload: 'subscription-test' }, { jobId })
509
+
510
+ const events: JobStatusSubscription[] = []
511
+ for await (const result of iterator) {
512
+ if (result.data) events.push(result.data)
513
+ }
514
+
515
+ expect(events.at(-1)?.jobStatus.status).toBe('Completed')
516
+ })
517
+ })
518
+ ```
519
+
520
+ The first argument is a `TypedSubscribeRequest` (`subscription`, `variables`, `operationName`); any further arguments are forwarded verbatim to the context creation function — in the example above, the optional `JwtPayload` produced by `buildUserJwt()`.
521
+
522
+ When pairing this helper with [`wrapSubscriptionIterator`](#wrapsubscriptioniterator), `eventIsFinal` will close the subscription once a terminal event arrives, ending the `for await` loop above without manual cleanup.
523
+
408
524
  ## \*Express peer dependency
409
525
 
410
526
  ApolloServer v3 standardises on the Express request representation.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "**"
5
5
  ],
6
6
  "name": "@makerx/graphql-core",
7
- "version": "3.0.1",
7
+ "version": "3.2.0",
8
8
  "private": false,
9
9
  "description": "A set of core GraphQL utilities that MakerX uses to build GraphQL APIs",
10
10
  "author": "MakerX",
@@ -21,6 +21,7 @@
21
21
  "url": "git+https://github.com/MakerXStudio/graphql-core.git"
22
22
  },
23
23
  "peerDependencies": {
24
+ "@graphql-typed-document-node/core": ">=3.2.0",
24
25
  "@makerx/node-common": ">=1",
25
26
  "es-toolkit": ">=1",
26
27
  "express": ">=4",
@@ -40,6 +41,9 @@
40
41
  "optional": true
41
42
  }
42
43
  },
44
+ "overrides": {
45
+ "esbuild": "^0.28.1"
46
+ },
43
47
  "main": "./index.js",
44
48
  "module": "./index.mjs",
45
49
  "types": "./index.d.ts",
@@ -74,6 +78,16 @@
74
78
  "types": "./subscriptions/index.d.cts",
75
79
  "default": "./subscriptions/index.js"
76
80
  }
81
+ },
82
+ "./testing": {
83
+ "import": {
84
+ "types": "./testing/index.d.mts",
85
+ "default": "./testing/index.mjs"
86
+ },
87
+ "require": {
88
+ "types": "./testing/index.d.cts",
89
+ "default": "./testing/index.js"
90
+ }
77
91
  }
78
92
  }
79
93
  }
@@ -1,3 +1,21 @@
1
+ /**
2
+ * A function that lazily produces an initial payload. Provided in place of a plain value when the
3
+ * caller needs the wrapped iterator's subscription to be established *before* the payload is read.
4
+ *
5
+ * May return `undefined` (or a promise of it) to indicate "no snapshot" — e.g. a repository
6
+ * `findOne` that yields nothing — in which case iteration proceeds straight to the wrapped stream.
7
+ */
8
+ export type InitialPayloadFactory<TEventData> = () => TEventData | TEventData[] | undefined | Promise<TEventData | TEventData[] | undefined>;
9
+ /**
10
+ * An initial payload: a single event, an array of events, or a factory that produces either (or
11
+ * `undefined` for "no snapshot").
12
+ *
13
+ * Note: the factory form is detected via `typeof initialPayload === 'function'`. If `TEventData` is
14
+ * itself a callable type, a value-form payload would be misclassified as a factory — wrap such a value
15
+ * in an array (`[value]`) to disambiguate. GraphQL event payloads are objects, so this is not a concern
16
+ * in practice.
17
+ */
18
+ export type InitialPayload<TEventData> = TEventData | TEventData[] | InitialPayloadFactory<TEventData>;
1
19
  /**
2
20
  * Wraps an async iterator with options for:
3
21
  * - returning an initial payload (e.g. current state)
@@ -8,16 +26,29 @@
8
26
  *
9
27
  * Eagerly ending iteration when a final event ensures subscriptions do not continue unnecessarily.
10
28
  *
29
+ * ### Avoiding the subscribe-before-snapshot race
30
+ *
31
+ * `graphql-subscriptions`' iterator subscribes to its channel lazily, on its first `next()`. If you read the
32
+ * snapshot (the initial payload) *before* constructing/pulling the iterator, any event published between the
33
+ * snapshot read and the subscription going live is lost — and missing from the snapshot too. To close that
34
+ * window, pass `initialPayload` as a **factory** (`() => ... | Promise<...>`). When a factory is supplied this
35
+ * helper eagerly pulls the wrapped iterator once (establishing the subscription) *before* invoking the factory
36
+ * to read the snapshot; events fired during the snapshot read are then buffered by the wrapped iterator instead
37
+ * of dropped, and are delivered immediately after the snapshot.
38
+ *
11
39
  * @param iterator The async iterator to wrap.
12
- * @param initialPayload An optional initial payload to return on the first iteration. May be a single event or an array.
40
+ * @param initialPayload An optional initial payload to return on the first iteration. May be a single event, an
41
+ * array, or a factory (`() => event | event[] | Promise<event | event[]>`). Prefer the factory form for the
42
+ * "snapshot then stream" pattern so the subscription is established before the snapshot is read.
13
43
  * @param eventIsFinal An optional function to determine if an event is final (and iteration should end).
14
44
  * The full `initialPayload` is always yielded; `eventIsFinal` is only checked against its final entry, after which
15
- * the wrapped iterator is closed without ever being pulled from. For events from the wrapped iterator, every event
16
- * is checked.
45
+ * the wrapped iterator is closed (`wrapped.return()`). In the value form it is closed without ever being pulled; in
46
+ * the factory form the single eager pull used to establish the subscription is discarded on teardown. For events
47
+ * from the wrapped iterator, every event is checked.
17
48
  * @returns An async iterator for the event data.
18
49
  */
19
50
  export declare function wrapSubscriptionIterator<TEventData>({ iterator: wrapped, initialPayload, eventIsFinal, }: {
20
51
  iterator: AsyncIterableIterator<TEventData>;
21
- initialPayload?: TEventData | TEventData[];
52
+ initialPayload?: InitialPayload<TEventData>;
22
53
  eventIsFinal?: (event: TEventData) => boolean;
23
54
  }): AsyncIterator<TEventData> & AsyncIterable<TEventData>;
@@ -1,3 +1,21 @@
1
+ /**
2
+ * A function that lazily produces an initial payload. Provided in place of a plain value when the
3
+ * caller needs the wrapped iterator's subscription to be established *before* the payload is read.
4
+ *
5
+ * May return `undefined` (or a promise of it) to indicate "no snapshot" — e.g. a repository
6
+ * `findOne` that yields nothing — in which case iteration proceeds straight to the wrapped stream.
7
+ */
8
+ export type InitialPayloadFactory<TEventData> = () => TEventData | TEventData[] | undefined | Promise<TEventData | TEventData[] | undefined>;
9
+ /**
10
+ * An initial payload: a single event, an array of events, or a factory that produces either (or
11
+ * `undefined` for "no snapshot").
12
+ *
13
+ * Note: the factory form is detected via `typeof initialPayload === 'function'`. If `TEventData` is
14
+ * itself a callable type, a value-form payload would be misclassified as a factory — wrap such a value
15
+ * in an array (`[value]`) to disambiguate. GraphQL event payloads are objects, so this is not a concern
16
+ * in practice.
17
+ */
18
+ export type InitialPayload<TEventData> = TEventData | TEventData[] | InitialPayloadFactory<TEventData>;
1
19
  /**
2
20
  * Wraps an async iterator with options for:
3
21
  * - returning an initial payload (e.g. current state)
@@ -8,16 +26,29 @@
8
26
  *
9
27
  * Eagerly ending iteration when a final event ensures subscriptions do not continue unnecessarily.
10
28
  *
29
+ * ### Avoiding the subscribe-before-snapshot race
30
+ *
31
+ * `graphql-subscriptions`' iterator subscribes to its channel lazily, on its first `next()`. If you read the
32
+ * snapshot (the initial payload) *before* constructing/pulling the iterator, any event published between the
33
+ * snapshot read and the subscription going live is lost — and missing from the snapshot too. To close that
34
+ * window, pass `initialPayload` as a **factory** (`() => ... | Promise<...>`). When a factory is supplied this
35
+ * helper eagerly pulls the wrapped iterator once (establishing the subscription) *before* invoking the factory
36
+ * to read the snapshot; events fired during the snapshot read are then buffered by the wrapped iterator instead
37
+ * of dropped, and are delivered immediately after the snapshot.
38
+ *
11
39
  * @param iterator The async iterator to wrap.
12
- * @param initialPayload An optional initial payload to return on the first iteration. May be a single event or an array.
40
+ * @param initialPayload An optional initial payload to return on the first iteration. May be a single event, an
41
+ * array, or a factory (`() => event | event[] | Promise<event | event[]>`). Prefer the factory form for the
42
+ * "snapshot then stream" pattern so the subscription is established before the snapshot is read.
13
43
  * @param eventIsFinal An optional function to determine if an event is final (and iteration should end).
14
44
  * The full `initialPayload` is always yielded; `eventIsFinal` is only checked against its final entry, after which
15
- * the wrapped iterator is closed without ever being pulled from. For events from the wrapped iterator, every event
16
- * is checked.
45
+ * the wrapped iterator is closed (`wrapped.return()`). In the value form it is closed without ever being pulled; in
46
+ * the factory form the single eager pull used to establish the subscription is discarded on teardown. For events
47
+ * from the wrapped iterator, every event is checked.
17
48
  * @returns An async iterator for the event data.
18
49
  */
19
50
  export declare function wrapSubscriptionIterator<TEventData>({ iterator: wrapped, initialPayload, eventIsFinal, }: {
20
51
  iterator: AsyncIterableIterator<TEventData>;
21
- initialPayload?: TEventData | TEventData[];
52
+ initialPayload?: InitialPayload<TEventData>;
22
53
  eventIsFinal?: (event: TEventData) => boolean;
23
54
  }): AsyncIterator<TEventData> & AsyncIterable<TEventData>;
@@ -1,3 +1,21 @@
1
+ /**
2
+ * A function that lazily produces an initial payload. Provided in place of a plain value when the
3
+ * caller needs the wrapped iterator's subscription to be established *before* the payload is read.
4
+ *
5
+ * May return `undefined` (or a promise of it) to indicate "no snapshot" — e.g. a repository
6
+ * `findOne` that yields nothing — in which case iteration proceeds straight to the wrapped stream.
7
+ */
8
+ export type InitialPayloadFactory<TEventData> = () => TEventData | TEventData[] | undefined | Promise<TEventData | TEventData[] | undefined>;
9
+ /**
10
+ * An initial payload: a single event, an array of events, or a factory that produces either (or
11
+ * `undefined` for "no snapshot").
12
+ *
13
+ * Note: the factory form is detected via `typeof initialPayload === 'function'`. If `TEventData` is
14
+ * itself a callable type, a value-form payload would be misclassified as a factory — wrap such a value
15
+ * in an array (`[value]`) to disambiguate. GraphQL event payloads are objects, so this is not a concern
16
+ * in practice.
17
+ */
18
+ export type InitialPayload<TEventData> = TEventData | TEventData[] | InitialPayloadFactory<TEventData>;
1
19
  /**
2
20
  * Wraps an async iterator with options for:
3
21
  * - returning an initial payload (e.g. current state)
@@ -8,16 +26,29 @@
8
26
  *
9
27
  * Eagerly ending iteration when a final event ensures subscriptions do not continue unnecessarily.
10
28
  *
29
+ * ### Avoiding the subscribe-before-snapshot race
30
+ *
31
+ * `graphql-subscriptions`' iterator subscribes to its channel lazily, on its first `next()`. If you read the
32
+ * snapshot (the initial payload) *before* constructing/pulling the iterator, any event published between the
33
+ * snapshot read and the subscription going live is lost — and missing from the snapshot too. To close that
34
+ * window, pass `initialPayload` as a **factory** (`() => ... | Promise<...>`). When a factory is supplied this
35
+ * helper eagerly pulls the wrapped iterator once (establishing the subscription) *before* invoking the factory
36
+ * to read the snapshot; events fired during the snapshot read are then buffered by the wrapped iterator instead
37
+ * of dropped, and are delivered immediately after the snapshot.
38
+ *
11
39
  * @param iterator The async iterator to wrap.
12
- * @param initialPayload An optional initial payload to return on the first iteration. May be a single event or an array.
40
+ * @param initialPayload An optional initial payload to return on the first iteration. May be a single event, an
41
+ * array, or a factory (`() => event | event[] | Promise<event | event[]>`). Prefer the factory form for the
42
+ * "snapshot then stream" pattern so the subscription is established before the snapshot is read.
13
43
  * @param eventIsFinal An optional function to determine if an event is final (and iteration should end).
14
44
  * The full `initialPayload` is always yielded; `eventIsFinal` is only checked against its final entry, after which
15
- * the wrapped iterator is closed without ever being pulled from. For events from the wrapped iterator, every event
16
- * is checked.
45
+ * the wrapped iterator is closed (`wrapped.return()`). In the value form it is closed without ever being pulled; in
46
+ * the factory form the single eager pull used to establish the subscription is discarded on teardown. For events
47
+ * from the wrapped iterator, every event is checked.
17
48
  * @returns An async iterator for the event data.
18
49
  */
19
50
  export declare function wrapSubscriptionIterator<TEventData>({ iterator: wrapped, initialPayload, eventIsFinal, }: {
20
51
  iterator: AsyncIterableIterator<TEventData>;
21
- initialPayload?: TEventData | TEventData[];
52
+ initialPayload?: InitialPayload<TEventData>;
22
53
  eventIsFinal?: (event: TEventData) => boolean;
23
54
  }): AsyncIterator<TEventData> & AsyncIterable<TEventData>;
@@ -10,30 +10,74 @@
10
10
  *
11
11
  * Eagerly ending iteration when a final event ensures subscriptions do not continue unnecessarily.
12
12
  *
13
+ * ### Avoiding the subscribe-before-snapshot race
14
+ *
15
+ * `graphql-subscriptions`' iterator subscribes to its channel lazily, on its first `next()`. If you read the
16
+ * snapshot (the initial payload) *before* constructing/pulling the iterator, any event published between the
17
+ * snapshot read and the subscription going live is lost — and missing from the snapshot too. To close that
18
+ * window, pass `initialPayload` as a **factory** (`() => ... | Promise<...>`). When a factory is supplied this
19
+ * helper eagerly pulls the wrapped iterator once (establishing the subscription) *before* invoking the factory
20
+ * to read the snapshot; events fired during the snapshot read are then buffered by the wrapped iterator instead
21
+ * of dropped, and are delivered immediately after the snapshot.
22
+ *
13
23
  * @param iterator The async iterator to wrap.
14
- * @param initialPayload An optional initial payload to return on the first iteration. May be a single event or an array.
24
+ * @param initialPayload An optional initial payload to return on the first iteration. May be a single event, an
25
+ * array, or a factory (`() => event | event[] | Promise<event | event[]>`). Prefer the factory form for the
26
+ * "snapshot then stream" pattern so the subscription is established before the snapshot is read.
15
27
  * @param eventIsFinal An optional function to determine if an event is final (and iteration should end).
16
28
  * The full `initialPayload` is always yielded; `eventIsFinal` is only checked against its final entry, after which
17
- * the wrapped iterator is closed without ever being pulled from. For events from the wrapped iterator, every event
18
- * is checked.
29
+ * the wrapped iterator is closed (`wrapped.return()`). In the value form it is closed without ever being pulled; in
30
+ * the factory form the single eager pull used to establish the subscription is discarded on teardown. For events
31
+ * from the wrapped iterator, every event is checked.
19
32
  * @returns An async iterator for the event data.
20
33
  */
21
34
  function wrapSubscriptionIterator({ iterator: wrapped, initialPayload, eventIsFinal, }) {
22
35
  let done = false;
23
- const initialQueue = initialPayload === undefined ? [] : Array.isArray(initialPayload) ? [...initialPayload] : [initialPayload];
36
+ const toQueue = (payload) => payload === undefined ? [] : Array.isArray(payload) ? [...payload] : [payload];
37
+ // Factory form: the caller wants the channel subscription established BEFORE the initial payload
38
+ // (typically a DB snapshot) is read. The wrapped iterator subscribes on its first `next()`, so pull it
39
+ // eagerly *now*. The resulting event is buffered and delivered after the initial payload drains; the
40
+ // factory (and therefore the snapshot read) runs lazily on the first consumer `next()`, after the
41
+ // subscription is already kicked.
42
+ const isFactory = typeof initialPayload === 'function';
43
+ let eagerPull = isFactory ? wrapped.next() : undefined;
44
+ // Value form: build the queue eagerly, exactly as before. Factory form: resolved lazily on first next().
45
+ let initialQueue = isFactory ? undefined : toQueue(initialPayload);
46
+ // Hand back the eagerly-pulled (= already-subscribed) event the first time, then defer to wrapped.next().
47
+ const consumeWrapped = () => {
48
+ if (eagerPull) {
49
+ const pull = eagerPull;
50
+ eagerPull = undefined;
51
+ return pull;
52
+ }
53
+ return wrapped.next();
54
+ };
55
+ // Abandon the eager pull without leaking an unhandled rejection. Only reached via early teardown
56
+ // (return/throw before the buffered event is consumed) or the eventIsFinal short-circuit on the
57
+ // snapshot — neither consumes the buffered event, and wrapped.return() tears down the kicked subscription.
58
+ const discardEagerPull = () => {
59
+ if (eagerPull) {
60
+ eagerPull.catch(() => { });
61
+ eagerPull = undefined;
62
+ }
63
+ };
24
64
  const iterator = {
25
65
  next: async () => {
26
66
  if (done)
27
67
  return { done: true, value: undefined };
68
+ if (initialQueue === undefined) {
69
+ initialQueue = toQueue(await initialPayload());
70
+ }
28
71
  if (initialQueue.length > 0) {
29
72
  const value = initialQueue.shift();
30
73
  if (initialQueue.length === 0 && eventIsFinal && eventIsFinal(value)) {
31
74
  done = true;
75
+ discardEagerPull(); // tear down the redundant eager subscription, if any
32
76
  await wrapped.return?.();
33
77
  }
34
78
  return { done: false, value };
35
79
  }
36
- const next = await wrapped.next();
80
+ const next = await consumeWrapped();
37
81
  if (!next.done && eventIsFinal && eventIsFinal(next.value)) {
38
82
  done = true;
39
83
  await wrapped.return?.();
@@ -42,14 +86,18 @@ function wrapSubscriptionIterator({ iterator: wrapped, initialPayload, eventIsFi
42
86
  },
43
87
  return: async (value) => {
44
88
  done = true;
45
- initialQueue.length = 0;
89
+ if (initialQueue)
90
+ initialQueue.length = 0;
91
+ discardEagerPull();
46
92
  if (wrapped.return)
47
93
  return wrapped.return(value);
48
94
  return { done: true, value: value };
49
95
  },
50
96
  throw: async (err) => {
51
97
  done = true;
52
- initialQueue.length = 0;
98
+ if (initialQueue)
99
+ initialQueue.length = 0;
100
+ discardEagerPull();
53
101
  if (wrapped.throw)
54
102
  return wrapped.throw(err);
55
103
  throw err;
@@ -1 +1 @@
1
- {"version":3,"file":"subscription-iterator.js","sources":["../../src/subscriptions/subscription-iterator.ts"],"sourcesContent":["/**\n * Wraps an async iterator with options for:\n * - returning an initial payload (e.g. current state)\n * - eagerly ending iteration when a final event is received\n *\n * Providing an initial payload helps avoid race conditions when clients subscribe to events but miss recent state changes,\n * by providing an initial payload to be immediately returned, before subsequent events stream in.\n *\n * Eagerly ending iteration when a final event ensures subscriptions do not continue unnecessarily.\n *\n * @param iterator The async iterator to wrap.\n * @param initialPayload An optional initial payload to return on the first iteration. May be a single event or an array.\n * @param eventIsFinal An optional function to determine if an event is final (and iteration should end).\n * The full `initialPayload` is always yielded; `eventIsFinal` is only checked against its final entry, after which\n * the wrapped iterator is closed without ever being pulled from. For events from the wrapped iterator, every event\n * is checked.\n * @returns An async iterator for the event data.\n */\nexport function wrapSubscriptionIterator<TEventData>({\n iterator: wrapped,\n initialPayload,\n eventIsFinal,\n}: {\n iterator: AsyncIterableIterator<TEventData>\n initialPayload?: TEventData | TEventData[]\n eventIsFinal?: (event: TEventData) => boolean\n}): AsyncIterator<TEventData> & AsyncIterable<TEventData> {\n let done = false\n const initialQueue: TEventData[] =\n initialPayload === undefined ? [] : Array.isArray(initialPayload) ? [...initialPayload] : [initialPayload]\n\n const iterator: AsyncIterator<TEventData> = {\n next: async () => {\n if (done) return { done: true, value: undefined }\n\n if (initialQueue.length > 0) {\n const value = initialQueue.shift() as TEventData\n if (initialQueue.length === 0 && eventIsFinal && eventIsFinal(value)) {\n done = true\n await wrapped.return?.()\n }\n return { done: false, value }\n }\n\n const next = await wrapped.next()\n if (!next.done && eventIsFinal && eventIsFinal(next.value)) {\n done = true\n await wrapped.return?.()\n }\n return next\n },\n return: async (value) => {\n done = true\n initialQueue.length = 0\n if (wrapped.return) return wrapped.return(value)\n return { done: true, value: value as TEventData }\n },\n throw: async (err) => {\n done = true\n initialQueue.length = 0\n if (wrapped.throw) return wrapped.throw(err)\n throw err\n },\n }\n\n return { ...iterator, [Symbol.asyncIterator]: () => iterator }\n}\n"],"names":[],"mappings":";;AAAA;;;;;;;;;;;;;;;;;AAiBG;AACG,SAAU,wBAAwB,CAAa,EACnD,QAAQ,EAAE,OAAO,EACjB,cAAc,EACd,YAAY,GAKb,EAAA;IACC,IAAI,IAAI,GAAG,KAAK;AAChB,IAAA,MAAM,YAAY,GAChB,cAAc,KAAK,SAAS,GAAG,EAAE,GAAG,KAAK,CAAC,OAAO,CAAC,cAAc,CAAC,GAAG,CAAC,GAAG,cAAc,CAAC,GAAG,CAAC,cAAc,CAAC;AAE5G,IAAA,MAAM,QAAQ,GAA8B;QAC1C,IAAI,EAAE,YAAW;AACf,YAAA,IAAI,IAAI;gBAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE;AAEjD,YAAA,IAAI,YAAY,CAAC,MAAM,GAAG,CAAC,EAAE;AAC3B,gBAAA,MAAM,KAAK,GAAG,YAAY,CAAC,KAAK,EAAgB;AAChD,gBAAA,IAAI,YAAY,CAAC,MAAM,KAAK,CAAC,IAAI,YAAY,IAAI,YAAY,CAAC,KAAK,CAAC,EAAE;oBACpE,IAAI,GAAG,IAAI;AACX,oBAAA,MAAM,OAAO,CAAC,MAAM,IAAI;gBAC1B;AACA,gBAAA,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE;YAC/B;AAEA,YAAA,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,IAAI,EAAE;AACjC,YAAA,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,YAAY,IAAI,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE;gBAC1D,IAAI,GAAG,IAAI;AACX,gBAAA,MAAM,OAAO,CAAC,MAAM,IAAI;YAC1B;AACA,YAAA,OAAO,IAAI;QACb,CAAC;AACD,QAAA,MAAM,EAAE,OAAO,KAAK,KAAI;YACtB,IAAI,GAAG,IAAI;AACX,YAAA,YAAY,CAAC,MAAM,GAAG,CAAC;YACvB,IAAI,OAAO,CAAC,MAAM;AAAE,gBAAA,OAAO,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC;YAChD,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAmB,EAAE;QACnD,CAAC;AACD,QAAA,KAAK,EAAE,OAAO,GAAG,KAAI;YACnB,IAAI,GAAG,IAAI;AACX,YAAA,YAAY,CAAC,MAAM,GAAG,CAAC;YACvB,IAAI,OAAO,CAAC,KAAK;AAAE,gBAAA,OAAO,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC;AAC5C,YAAA,MAAM,GAAG;QACX,CAAC;KACF;AAED,IAAA,OAAO,EAAE,GAAG,QAAQ,EAAE,CAAC,MAAM,CAAC,aAAa,GAAG,MAAM,QAAQ,EAAE;AAChE;;;;"}
1
+ {"version":3,"file":"subscription-iterator.js","sources":["../../src/subscriptions/subscription-iterator.ts"],"sourcesContent":["/**\n * A function that lazily produces an initial payload. Provided in place of a plain value when the\n * caller needs the wrapped iterator's subscription to be established *before* the payload is read.\n *\n * May return `undefined` (or a promise of it) to indicate \"no snapshot\" — e.g. a repository\n * `findOne` that yields nothing — in which case iteration proceeds straight to the wrapped stream.\n */\nexport type InitialPayloadFactory<TEventData> = () => TEventData | TEventData[] | undefined | Promise<TEventData | TEventData[] | undefined>\n\n/**\n * An initial payload: a single event, an array of events, or a factory that produces either (or\n * `undefined` for \"no snapshot\").\n *\n * Note: the factory form is detected via `typeof initialPayload === 'function'`. If `TEventData` is\n * itself a callable type, a value-form payload would be misclassified as a factory — wrap such a value\n * in an array (`[value]`) to disambiguate. GraphQL event payloads are objects, so this is not a concern\n * in practice.\n */\nexport type InitialPayload<TEventData> = TEventData | TEventData[] | InitialPayloadFactory<TEventData>\n\n/**\n * Wraps an async iterator with options for:\n * - returning an initial payload (e.g. current state)\n * - eagerly ending iteration when a final event is received\n *\n * Providing an initial payload helps avoid race conditions when clients subscribe to events but miss recent state changes,\n * by providing an initial payload to be immediately returned, before subsequent events stream in.\n *\n * Eagerly ending iteration when a final event ensures subscriptions do not continue unnecessarily.\n *\n * ### Avoiding the subscribe-before-snapshot race\n *\n * `graphql-subscriptions`' iterator subscribes to its channel lazily, on its first `next()`. If you read the\n * snapshot (the initial payload) *before* constructing/pulling the iterator, any event published between the\n * snapshot read and the subscription going live is lost — and missing from the snapshot too. To close that\n * window, pass `initialPayload` as a **factory** (`() => ... | Promise<...>`). When a factory is supplied this\n * helper eagerly pulls the wrapped iterator once (establishing the subscription) *before* invoking the factory\n * to read the snapshot; events fired during the snapshot read are then buffered by the wrapped iterator instead\n * of dropped, and are delivered immediately after the snapshot.\n *\n * @param iterator The async iterator to wrap.\n * @param initialPayload An optional initial payload to return on the first iteration. May be a single event, an\n * array, or a factory (`() => event | event[] | Promise<event | event[]>`). Prefer the factory form for the\n * \"snapshot then stream\" pattern so the subscription is established before the snapshot is read.\n * @param eventIsFinal An optional function to determine if an event is final (and iteration should end).\n * The full `initialPayload` is always yielded; `eventIsFinal` is only checked against its final entry, after which\n * the wrapped iterator is closed (`wrapped.return()`). In the value form it is closed without ever being pulled; in\n * the factory form the single eager pull used to establish the subscription is discarded on teardown. For events\n * from the wrapped iterator, every event is checked.\n * @returns An async iterator for the event data.\n */\nexport function wrapSubscriptionIterator<TEventData>({\n iterator: wrapped,\n initialPayload,\n eventIsFinal,\n}: {\n iterator: AsyncIterableIterator<TEventData>\n initialPayload?: InitialPayload<TEventData>\n eventIsFinal?: (event: TEventData) => boolean\n}): AsyncIterator<TEventData> & AsyncIterable<TEventData> {\n let done = false\n\n const toQueue = (payload: TEventData | TEventData[] | undefined): TEventData[] =>\n payload === undefined ? [] : Array.isArray(payload) ? [...payload] : [payload]\n\n // Factory form: the caller wants the channel subscription established BEFORE the initial payload\n // (typically a DB snapshot) is read. The wrapped iterator subscribes on its first `next()`, so pull it\n // eagerly *now*. The resulting event is buffered and delivered after the initial payload drains; the\n // factory (and therefore the snapshot read) runs lazily on the first consumer `next()`, after the\n // subscription is already kicked.\n const isFactory = typeof initialPayload === 'function'\n let eagerPull: Promise<IteratorResult<TEventData>> | undefined = isFactory ? wrapped.next() : undefined\n\n // Value form: build the queue eagerly, exactly as before. Factory form: resolved lazily on first next().\n let initialQueue: TEventData[] | undefined = isFactory ? undefined : toQueue(initialPayload as TEventData | TEventData[] | undefined)\n\n // Hand back the eagerly-pulled (= already-subscribed) event the first time, then defer to wrapped.next().\n const consumeWrapped = (): Promise<IteratorResult<TEventData>> => {\n if (eagerPull) {\n const pull = eagerPull\n eagerPull = undefined\n return pull\n }\n return wrapped.next()\n }\n\n // Abandon the eager pull without leaking an unhandled rejection. Only reached via early teardown\n // (return/throw before the buffered event is consumed) or the eventIsFinal short-circuit on the\n // snapshot — neither consumes the buffered event, and wrapped.return() tears down the kicked subscription.\n const discardEagerPull = () => {\n if (eagerPull) {\n eagerPull.catch(() => {})\n eagerPull = undefined\n }\n }\n\n const iterator: AsyncIterator<TEventData> = {\n next: async () => {\n if (done) return { done: true, value: undefined }\n\n if (initialQueue === undefined) {\n initialQueue = toQueue(await (initialPayload as InitialPayloadFactory<TEventData>)())\n }\n\n if (initialQueue.length > 0) {\n const value = initialQueue.shift() as TEventData\n if (initialQueue.length === 0 && eventIsFinal && eventIsFinal(value)) {\n done = true\n discardEagerPull() // tear down the redundant eager subscription, if any\n await wrapped.return?.()\n }\n return { done: false, value }\n }\n\n const next = await consumeWrapped()\n if (!next.done && eventIsFinal && eventIsFinal(next.value)) {\n done = true\n await wrapped.return?.()\n }\n return next\n },\n return: async (value) => {\n done = true\n if (initialQueue) initialQueue.length = 0\n discardEagerPull()\n if (wrapped.return) return wrapped.return(value)\n return { done: true, value: value as TEventData }\n },\n throw: async (err) => {\n done = true\n if (initialQueue) initialQueue.length = 0\n discardEagerPull()\n if (wrapped.throw) return wrapped.throw(err)\n throw err\n },\n }\n\n return { ...iterator, [Symbol.asyncIterator]: () => iterator }\n}\n"],"names":[],"mappings":"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"}
@@ -8,30 +8,74 @@
8
8
  *
9
9
  * Eagerly ending iteration when a final event ensures subscriptions do not continue unnecessarily.
10
10
  *
11
+ * ### Avoiding the subscribe-before-snapshot race
12
+ *
13
+ * `graphql-subscriptions`' iterator subscribes to its channel lazily, on its first `next()`. If you read the
14
+ * snapshot (the initial payload) *before* constructing/pulling the iterator, any event published between the
15
+ * snapshot read and the subscription going live is lost — and missing from the snapshot too. To close that
16
+ * window, pass `initialPayload` as a **factory** (`() => ... | Promise<...>`). When a factory is supplied this
17
+ * helper eagerly pulls the wrapped iterator once (establishing the subscription) *before* invoking the factory
18
+ * to read the snapshot; events fired during the snapshot read are then buffered by the wrapped iterator instead
19
+ * of dropped, and are delivered immediately after the snapshot.
20
+ *
11
21
  * @param iterator The async iterator to wrap.
12
- * @param initialPayload An optional initial payload to return on the first iteration. May be a single event or an array.
22
+ * @param initialPayload An optional initial payload to return on the first iteration. May be a single event, an
23
+ * array, or a factory (`() => event | event[] | Promise<event | event[]>`). Prefer the factory form for the
24
+ * "snapshot then stream" pattern so the subscription is established before the snapshot is read.
13
25
  * @param eventIsFinal An optional function to determine if an event is final (and iteration should end).
14
26
  * The full `initialPayload` is always yielded; `eventIsFinal` is only checked against its final entry, after which
15
- * the wrapped iterator is closed without ever being pulled from. For events from the wrapped iterator, every event
16
- * is checked.
27
+ * the wrapped iterator is closed (`wrapped.return()`). In the value form it is closed without ever being pulled; in
28
+ * the factory form the single eager pull used to establish the subscription is discarded on teardown. For events
29
+ * from the wrapped iterator, every event is checked.
17
30
  * @returns An async iterator for the event data.
18
31
  */
19
32
  function wrapSubscriptionIterator({ iterator: wrapped, initialPayload, eventIsFinal, }) {
20
33
  let done = false;
21
- const initialQueue = initialPayload === undefined ? [] : Array.isArray(initialPayload) ? [...initialPayload] : [initialPayload];
34
+ const toQueue = (payload) => payload === undefined ? [] : Array.isArray(payload) ? [...payload] : [payload];
35
+ // Factory form: the caller wants the channel subscription established BEFORE the initial payload
36
+ // (typically a DB snapshot) is read. The wrapped iterator subscribes on its first `next()`, so pull it
37
+ // eagerly *now*. The resulting event is buffered and delivered after the initial payload drains; the
38
+ // factory (and therefore the snapshot read) runs lazily on the first consumer `next()`, after the
39
+ // subscription is already kicked.
40
+ const isFactory = typeof initialPayload === 'function';
41
+ let eagerPull = isFactory ? wrapped.next() : undefined;
42
+ // Value form: build the queue eagerly, exactly as before. Factory form: resolved lazily on first next().
43
+ let initialQueue = isFactory ? undefined : toQueue(initialPayload);
44
+ // Hand back the eagerly-pulled (= already-subscribed) event the first time, then defer to wrapped.next().
45
+ const consumeWrapped = () => {
46
+ if (eagerPull) {
47
+ const pull = eagerPull;
48
+ eagerPull = undefined;
49
+ return pull;
50
+ }
51
+ return wrapped.next();
52
+ };
53
+ // Abandon the eager pull without leaking an unhandled rejection. Only reached via early teardown
54
+ // (return/throw before the buffered event is consumed) or the eventIsFinal short-circuit on the
55
+ // snapshot — neither consumes the buffered event, and wrapped.return() tears down the kicked subscription.
56
+ const discardEagerPull = () => {
57
+ if (eagerPull) {
58
+ eagerPull.catch(() => { });
59
+ eagerPull = undefined;
60
+ }
61
+ };
22
62
  const iterator = {
23
63
  next: async () => {
24
64
  if (done)
25
65
  return { done: true, value: undefined };
66
+ if (initialQueue === undefined) {
67
+ initialQueue = toQueue(await initialPayload());
68
+ }
26
69
  if (initialQueue.length > 0) {
27
70
  const value = initialQueue.shift();
28
71
  if (initialQueue.length === 0 && eventIsFinal && eventIsFinal(value)) {
29
72
  done = true;
73
+ discardEagerPull(); // tear down the redundant eager subscription, if any
30
74
  await wrapped.return?.();
31
75
  }
32
76
  return { done: false, value };
33
77
  }
34
- const next = await wrapped.next();
78
+ const next = await consumeWrapped();
35
79
  if (!next.done && eventIsFinal && eventIsFinal(next.value)) {
36
80
  done = true;
37
81
  await wrapped.return?.();
@@ -40,14 +84,18 @@ function wrapSubscriptionIterator({ iterator: wrapped, initialPayload, eventIsFi
40
84
  },
41
85
  return: async (value) => {
42
86
  done = true;
43
- initialQueue.length = 0;
87
+ if (initialQueue)
88
+ initialQueue.length = 0;
89
+ discardEagerPull();
44
90
  if (wrapped.return)
45
91
  return wrapped.return(value);
46
92
  return { done: true, value: value };
47
93
  },
48
94
  throw: async (err) => {
49
95
  done = true;
50
- initialQueue.length = 0;
96
+ if (initialQueue)
97
+ initialQueue.length = 0;
98
+ discardEagerPull();
51
99
  if (wrapped.throw)
52
100
  return wrapped.throw(err);
53
101
  throw err;
@@ -1 +1 @@
1
- {"version":3,"file":"subscription-iterator.mjs","sources":["../../src/subscriptions/subscription-iterator.ts"],"sourcesContent":["/**\n * Wraps an async iterator with options for:\n * - returning an initial payload (e.g. current state)\n * - eagerly ending iteration when a final event is received\n *\n * Providing an initial payload helps avoid race conditions when clients subscribe to events but miss recent state changes,\n * by providing an initial payload to be immediately returned, before subsequent events stream in.\n *\n * Eagerly ending iteration when a final event ensures subscriptions do not continue unnecessarily.\n *\n * @param iterator The async iterator to wrap.\n * @param initialPayload An optional initial payload to return on the first iteration. May be a single event or an array.\n * @param eventIsFinal An optional function to determine if an event is final (and iteration should end).\n * The full `initialPayload` is always yielded; `eventIsFinal` is only checked against its final entry, after which\n * the wrapped iterator is closed without ever being pulled from. For events from the wrapped iterator, every event\n * is checked.\n * @returns An async iterator for the event data.\n */\nexport function wrapSubscriptionIterator<TEventData>({\n iterator: wrapped,\n initialPayload,\n eventIsFinal,\n}: {\n iterator: AsyncIterableIterator<TEventData>\n initialPayload?: TEventData | TEventData[]\n eventIsFinal?: (event: TEventData) => boolean\n}): AsyncIterator<TEventData> & AsyncIterable<TEventData> {\n let done = false\n const initialQueue: TEventData[] =\n initialPayload === undefined ? [] : Array.isArray(initialPayload) ? [...initialPayload] : [initialPayload]\n\n const iterator: AsyncIterator<TEventData> = {\n next: async () => {\n if (done) return { done: true, value: undefined }\n\n if (initialQueue.length > 0) {\n const value = initialQueue.shift() as TEventData\n if (initialQueue.length === 0 && eventIsFinal && eventIsFinal(value)) {\n done = true\n await wrapped.return?.()\n }\n return { done: false, value }\n }\n\n const next = await wrapped.next()\n if (!next.done && eventIsFinal && eventIsFinal(next.value)) {\n done = true\n await wrapped.return?.()\n }\n return next\n },\n return: async (value) => {\n done = true\n initialQueue.length = 0\n if (wrapped.return) return wrapped.return(value)\n return { done: true, value: value as TEventData }\n },\n throw: async (err) => {\n done = true\n initialQueue.length = 0\n if (wrapped.throw) return wrapped.throw(err)\n throw err\n },\n }\n\n return { ...iterator, [Symbol.asyncIterator]: () => iterator }\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;AAiBG;AACG,SAAU,wBAAwB,CAAa,EACnD,QAAQ,EAAE,OAAO,EACjB,cAAc,EACd,YAAY,GAKb,EAAA;IACC,IAAI,IAAI,GAAG,KAAK;AAChB,IAAA,MAAM,YAAY,GAChB,cAAc,KAAK,SAAS,GAAG,EAAE,GAAG,KAAK,CAAC,OAAO,CAAC,cAAc,CAAC,GAAG,CAAC,GAAG,cAAc,CAAC,GAAG,CAAC,cAAc,CAAC;AAE5G,IAAA,MAAM,QAAQ,GAA8B;QAC1C,IAAI,EAAE,YAAW;AACf,YAAA,IAAI,IAAI;gBAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE;AAEjD,YAAA,IAAI,YAAY,CAAC,MAAM,GAAG,CAAC,EAAE;AAC3B,gBAAA,MAAM,KAAK,GAAG,YAAY,CAAC,KAAK,EAAgB;AAChD,gBAAA,IAAI,YAAY,CAAC,MAAM,KAAK,CAAC,IAAI,YAAY,IAAI,YAAY,CAAC,KAAK,CAAC,EAAE;oBACpE,IAAI,GAAG,IAAI;AACX,oBAAA,MAAM,OAAO,CAAC,MAAM,IAAI;gBAC1B;AACA,gBAAA,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE;YAC/B;AAEA,YAAA,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,IAAI,EAAE;AACjC,YAAA,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,YAAY,IAAI,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE;gBAC1D,IAAI,GAAG,IAAI;AACX,gBAAA,MAAM,OAAO,CAAC,MAAM,IAAI;YAC1B;AACA,YAAA,OAAO,IAAI;QACb,CAAC;AACD,QAAA,MAAM,EAAE,OAAO,KAAK,KAAI;YACtB,IAAI,GAAG,IAAI;AACX,YAAA,YAAY,CAAC,MAAM,GAAG,CAAC;YACvB,IAAI,OAAO,CAAC,MAAM;AAAE,gBAAA,OAAO,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC;YAChD,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAmB,EAAE;QACnD,CAAC;AACD,QAAA,KAAK,EAAE,OAAO,GAAG,KAAI;YACnB,IAAI,GAAG,IAAI;AACX,YAAA,YAAY,CAAC,MAAM,GAAG,CAAC;YACvB,IAAI,OAAO,CAAC,KAAK;AAAE,gBAAA,OAAO,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC;AAC5C,YAAA,MAAM,GAAG;QACX,CAAC;KACF;AAED,IAAA,OAAO,EAAE,GAAG,QAAQ,EAAE,CAAC,MAAM,CAAC,aAAa,GAAG,MAAM,QAAQ,EAAE;AAChE;;;;"}
1
+ {"version":3,"file":"subscription-iterator.mjs","sources":["../../src/subscriptions/subscription-iterator.ts"],"sourcesContent":["/**\n * A function that lazily produces an initial payload. Provided in place of a plain value when the\n * caller needs the wrapped iterator's subscription to be established *before* the payload is read.\n *\n * May return `undefined` (or a promise of it) to indicate \"no snapshot\" — e.g. a repository\n * `findOne` that yields nothing — in which case iteration proceeds straight to the wrapped stream.\n */\nexport type InitialPayloadFactory<TEventData> = () => TEventData | TEventData[] | undefined | Promise<TEventData | TEventData[] | undefined>\n\n/**\n * An initial payload: a single event, an array of events, or a factory that produces either (or\n * `undefined` for \"no snapshot\").\n *\n * Note: the factory form is detected via `typeof initialPayload === 'function'`. If `TEventData` is\n * itself a callable type, a value-form payload would be misclassified as a factory — wrap such a value\n * in an array (`[value]`) to disambiguate. GraphQL event payloads are objects, so this is not a concern\n * in practice.\n */\nexport type InitialPayload<TEventData> = TEventData | TEventData[] | InitialPayloadFactory<TEventData>\n\n/**\n * Wraps an async iterator with options for:\n * - returning an initial payload (e.g. current state)\n * - eagerly ending iteration when a final event is received\n *\n * Providing an initial payload helps avoid race conditions when clients subscribe to events but miss recent state changes,\n * by providing an initial payload to be immediately returned, before subsequent events stream in.\n *\n * Eagerly ending iteration when a final event ensures subscriptions do not continue unnecessarily.\n *\n * ### Avoiding the subscribe-before-snapshot race\n *\n * `graphql-subscriptions`' iterator subscribes to its channel lazily, on its first `next()`. If you read the\n * snapshot (the initial payload) *before* constructing/pulling the iterator, any event published between the\n * snapshot read and the subscription going live is lost — and missing from the snapshot too. To close that\n * window, pass `initialPayload` as a **factory** (`() => ... | Promise<...>`). When a factory is supplied this\n * helper eagerly pulls the wrapped iterator once (establishing the subscription) *before* invoking the factory\n * to read the snapshot; events fired during the snapshot read are then buffered by the wrapped iterator instead\n * of dropped, and are delivered immediately after the snapshot.\n *\n * @param iterator The async iterator to wrap.\n * @param initialPayload An optional initial payload to return on the first iteration. May be a single event, an\n * array, or a factory (`() => event | event[] | Promise<event | event[]>`). Prefer the factory form for the\n * \"snapshot then stream\" pattern so the subscription is established before the snapshot is read.\n * @param eventIsFinal An optional function to determine if an event is final (and iteration should end).\n * The full `initialPayload` is always yielded; `eventIsFinal` is only checked against its final entry, after which\n * the wrapped iterator is closed (`wrapped.return()`). In the value form it is closed without ever being pulled; in\n * the factory form the single eager pull used to establish the subscription is discarded on teardown. For events\n * from the wrapped iterator, every event is checked.\n * @returns An async iterator for the event data.\n */\nexport function wrapSubscriptionIterator<TEventData>({\n iterator: wrapped,\n initialPayload,\n eventIsFinal,\n}: {\n iterator: AsyncIterableIterator<TEventData>\n initialPayload?: InitialPayload<TEventData>\n eventIsFinal?: (event: TEventData) => boolean\n}): AsyncIterator<TEventData> & AsyncIterable<TEventData> {\n let done = false\n\n const toQueue = (payload: TEventData | TEventData[] | undefined): TEventData[] =>\n payload === undefined ? [] : Array.isArray(payload) ? [...payload] : [payload]\n\n // Factory form: the caller wants the channel subscription established BEFORE the initial payload\n // (typically a DB snapshot) is read. The wrapped iterator subscribes on its first `next()`, so pull it\n // eagerly *now*. The resulting event is buffered and delivered after the initial payload drains; the\n // factory (and therefore the snapshot read) runs lazily on the first consumer `next()`, after the\n // subscription is already kicked.\n const isFactory = typeof initialPayload === 'function'\n let eagerPull: Promise<IteratorResult<TEventData>> | undefined = isFactory ? wrapped.next() : undefined\n\n // Value form: build the queue eagerly, exactly as before. Factory form: resolved lazily on first next().\n let initialQueue: TEventData[] | undefined = isFactory ? undefined : toQueue(initialPayload as TEventData | TEventData[] | undefined)\n\n // Hand back the eagerly-pulled (= already-subscribed) event the first time, then defer to wrapped.next().\n const consumeWrapped = (): Promise<IteratorResult<TEventData>> => {\n if (eagerPull) {\n const pull = eagerPull\n eagerPull = undefined\n return pull\n }\n return wrapped.next()\n }\n\n // Abandon the eager pull without leaking an unhandled rejection. Only reached via early teardown\n // (return/throw before the buffered event is consumed) or the eventIsFinal short-circuit on the\n // snapshot — neither consumes the buffered event, and wrapped.return() tears down the kicked subscription.\n const discardEagerPull = () => {\n if (eagerPull) {\n eagerPull.catch(() => {})\n eagerPull = undefined\n }\n }\n\n const iterator: AsyncIterator<TEventData> = {\n next: async () => {\n if (done) return { done: true, value: undefined }\n\n if (initialQueue === undefined) {\n initialQueue = toQueue(await (initialPayload as InitialPayloadFactory<TEventData>)())\n }\n\n if (initialQueue.length > 0) {\n const value = initialQueue.shift() as TEventData\n if (initialQueue.length === 0 && eventIsFinal && eventIsFinal(value)) {\n done = true\n discardEagerPull() // tear down the redundant eager subscription, if any\n await wrapped.return?.()\n }\n return { done: false, value }\n }\n\n const next = await consumeWrapped()\n if (!next.done && eventIsFinal && eventIsFinal(next.value)) {\n done = true\n await wrapped.return?.()\n }\n return next\n },\n return: async (value) => {\n done = true\n if (initialQueue) initialQueue.length = 0\n discardEagerPull()\n if (wrapped.return) return wrapped.return(value)\n return { done: true, value: value as TEventData }\n },\n throw: async (err) => {\n done = true\n if (initialQueue) initialQueue.length = 0\n discardEagerPull()\n if (wrapped.throw) return wrapped.throw(err)\n throw err\n },\n }\n\n return { ...iterator, [Symbol.asyncIterator]: () => iterator }\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export * from './subscribe-operation';
@@ -0,0 +1 @@
1
+ export * from './subscribe-operation.mjs';
@@ -0,0 +1 @@
1
+ export * from './subscribe-operation';
@@ -0,0 +1,8 @@
1
+ 'use strict';
2
+
3
+ var subscribeOperation = require('./subscribe-operation.js');
4
+
5
+
6
+
7
+ exports.buildSubscribeOperation = subscribeOperation.buildSubscribeOperation;
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;"}
@@ -0,0 +1,2 @@
1
+ export { buildSubscribeOperation } from './subscribe-operation.mjs';
2
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","sources":[],"sourcesContent":[],"names":[],"mappings":""}
@@ -0,0 +1,31 @@
1
+ import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
2
+ import { type DocumentNode, type ExecutionResult, type GraphQLSchema } from 'graphql';
3
+ import type { AnyGraphqlContext } from '../context';
4
+ export type VariableValues = {
5
+ [key: string]: any;
6
+ };
7
+ export type TypedSubscribeRequest<TData = Record<string, unknown>, TVariables extends VariableValues = VariableValues> = {
8
+ subscription: string | DocumentNode | TypedDocumentNode<TData, TVariables>;
9
+ variables?: TVariables;
10
+ operationName?: string;
11
+ };
12
+ /**
13
+ * Returns a `subscribeOperation` test helper for the provided schema and context creation function.
14
+ *
15
+ * The returned function runs a subscription operation directly against the schema using `graphql.subscribe`,
16
+ * bypassing the websocket / `graphql-ws` transport. This gives tests complete control over JWT payloads
17
+ * and other context inputs, the same way `buildExecuteOperation` does for queries and mutations in
18
+ * `@makerx/graphql-apollo-server/testing`.
19
+ *
20
+ * The returned function:
21
+ * - is strongly typed to the GraphQL context
22
+ * - accepts `TypedDocumentNode` subscriptions to provide strong operation typing
23
+ * - forwards any additional arguments to the supplied context creation function
24
+ * - returns the subscription's `AsyncIterableIterator<ExecutionResult<TData>>` so tests can consume events via `for await` or manual `next()` calls
25
+ * - throws if the subscription fails to produce an iterator (e.g. a validation error), surfacing the underlying errors
26
+ *
27
+ * @param schema The executable `GraphQLSchema`, or a factory that resolves one (useful when schema construction is async).
28
+ * @param createContext A context creation function. Any args supplied after the request are forwarded to it,
29
+ * so test fixtures can drive auth/user state per-call (e.g. `(jwtPayload?) => Promise<GraphQLContext>`).
30
+ */
31
+ export declare function buildSubscribeOperation<TContext extends AnyGraphqlContext, TContextFunction extends (...args: any) => Promise<TContext>>(schema: GraphQLSchema | (() => Promise<GraphQLSchema>), createContext: TContextFunction): <TData = Record<string, unknown>, TVariables extends VariableValues = VariableValues>({ subscription, variables, operationName }: TypedSubscribeRequest<TData, TVariables>, ...createContextArgs: Parameters<TContextFunction>) => Promise<AsyncIterableIterator<ExecutionResult<TData>>>;
@@ -0,0 +1,31 @@
1
+ import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
2
+ import { type DocumentNode, type ExecutionResult, type GraphQLSchema } from 'graphql';
3
+ import type { AnyGraphqlContext } from '../context.mjs';
4
+ export type VariableValues = {
5
+ [key: string]: any;
6
+ };
7
+ export type TypedSubscribeRequest<TData = Record<string, unknown>, TVariables extends VariableValues = VariableValues> = {
8
+ subscription: string | DocumentNode | TypedDocumentNode<TData, TVariables>;
9
+ variables?: TVariables;
10
+ operationName?: string;
11
+ };
12
+ /**
13
+ * Returns a `subscribeOperation` test helper for the provided schema and context creation function.
14
+ *
15
+ * The returned function runs a subscription operation directly against the schema using `graphql.subscribe`,
16
+ * bypassing the websocket / `graphql-ws` transport. This gives tests complete control over JWT payloads
17
+ * and other context inputs, the same way `buildExecuteOperation` does for queries and mutations in
18
+ * `@makerx/graphql-apollo-server/testing`.
19
+ *
20
+ * The returned function:
21
+ * - is strongly typed to the GraphQL context
22
+ * - accepts `TypedDocumentNode` subscriptions to provide strong operation typing
23
+ * - forwards any additional arguments to the supplied context creation function
24
+ * - returns the subscription's `AsyncIterableIterator<ExecutionResult<TData>>` so tests can consume events via `for await` or manual `next()` calls
25
+ * - throws if the subscription fails to produce an iterator (e.g. a validation error), surfacing the underlying errors
26
+ *
27
+ * @param schema The executable `GraphQLSchema`, or a factory that resolves one (useful when schema construction is async).
28
+ * @param createContext A context creation function. Any args supplied after the request are forwarded to it,
29
+ * so test fixtures can drive auth/user state per-call (e.g. `(jwtPayload?) => Promise<GraphQLContext>`).
30
+ */
31
+ export declare function buildSubscribeOperation<TContext extends AnyGraphqlContext, TContextFunction extends (...args: any) => Promise<TContext>>(schema: GraphQLSchema | (() => Promise<GraphQLSchema>), createContext: TContextFunction): <TData = Record<string, unknown>, TVariables extends VariableValues = VariableValues>({ subscription, variables, operationName }: TypedSubscribeRequest<TData, TVariables>, ...createContextArgs: Parameters<TContextFunction>) => Promise<AsyncIterableIterator<ExecutionResult<TData>>>;
@@ -0,0 +1,31 @@
1
+ import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
2
+ import { type DocumentNode, type ExecutionResult, type GraphQLSchema } from 'graphql';
3
+ import type { AnyGraphqlContext } from '../context';
4
+ export type VariableValues = {
5
+ [key: string]: any;
6
+ };
7
+ export type TypedSubscribeRequest<TData = Record<string, unknown>, TVariables extends VariableValues = VariableValues> = {
8
+ subscription: string | DocumentNode | TypedDocumentNode<TData, TVariables>;
9
+ variables?: TVariables;
10
+ operationName?: string;
11
+ };
12
+ /**
13
+ * Returns a `subscribeOperation` test helper for the provided schema and context creation function.
14
+ *
15
+ * The returned function runs a subscription operation directly against the schema using `graphql.subscribe`,
16
+ * bypassing the websocket / `graphql-ws` transport. This gives tests complete control over JWT payloads
17
+ * and other context inputs, the same way `buildExecuteOperation` does for queries and mutations in
18
+ * `@makerx/graphql-apollo-server/testing`.
19
+ *
20
+ * The returned function:
21
+ * - is strongly typed to the GraphQL context
22
+ * - accepts `TypedDocumentNode` subscriptions to provide strong operation typing
23
+ * - forwards any additional arguments to the supplied context creation function
24
+ * - returns the subscription's `AsyncIterableIterator<ExecutionResult<TData>>` so tests can consume events via `for await` or manual `next()` calls
25
+ * - throws if the subscription fails to produce an iterator (e.g. a validation error), surfacing the underlying errors
26
+ *
27
+ * @param schema The executable `GraphQLSchema`, or a factory that resolves one (useful when schema construction is async).
28
+ * @param createContext A context creation function. Any args supplied after the request are forwarded to it,
29
+ * so test fixtures can drive auth/user state per-call (e.g. `(jwtPayload?) => Promise<GraphQLContext>`).
30
+ */
31
+ export declare function buildSubscribeOperation<TContext extends AnyGraphqlContext, TContextFunction extends (...args: any) => Promise<TContext>>(schema: GraphQLSchema | (() => Promise<GraphQLSchema>), createContext: TContextFunction): <TData = Record<string, unknown>, TVariables extends VariableValues = VariableValues>({ subscription, variables, operationName }: TypedSubscribeRequest<TData, TVariables>, ...createContextArgs: Parameters<TContextFunction>) => Promise<AsyncIterableIterator<ExecutionResult<TData>>>;
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ var graphql = require('graphql');
4
+
5
+ /**
6
+ * Returns a `subscribeOperation` test helper for the provided schema and context creation function.
7
+ *
8
+ * The returned function runs a subscription operation directly against the schema using `graphql.subscribe`,
9
+ * bypassing the websocket / `graphql-ws` transport. This gives tests complete control over JWT payloads
10
+ * and other context inputs, the same way `buildExecuteOperation` does for queries and mutations in
11
+ * `@makerx/graphql-apollo-server/testing`.
12
+ *
13
+ * The returned function:
14
+ * - is strongly typed to the GraphQL context
15
+ * - accepts `TypedDocumentNode` subscriptions to provide strong operation typing
16
+ * - forwards any additional arguments to the supplied context creation function
17
+ * - returns the subscription's `AsyncIterableIterator<ExecutionResult<TData>>` so tests can consume events via `for await` or manual `next()` calls
18
+ * - throws if the subscription fails to produce an iterator (e.g. a validation error), surfacing the underlying errors
19
+ *
20
+ * @param schema The executable `GraphQLSchema`, or a factory that resolves one (useful when schema construction is async).
21
+ * @param createContext A context creation function. Any args supplied after the request are forwarded to it,
22
+ * so test fixtures can drive auth/user state per-call (e.g. `(jwtPayload?) => Promise<GraphQLContext>`).
23
+ */
24
+ function buildSubscribeOperation(schema, createContext) {
25
+ return async function subscribeOperation({ subscription, variables, operationName }, ...createContextArgs) {
26
+ const resolvedSchema = typeof schema === 'function' ? await schema() : schema;
27
+ const document = typeof subscription === 'string' ? graphql.parse(subscription) : subscription;
28
+ const contextValue = await createContext(...createContextArgs);
29
+ const result = await graphql.subscribe({
30
+ schema: resolvedSchema,
31
+ document,
32
+ contextValue,
33
+ variableValues: variables,
34
+ operationName,
35
+ });
36
+ if (Symbol.asyncIterator in result)
37
+ return result;
38
+ throw new Error(`Subscription did not produce an iterator: ${JSON.stringify(result.errors)}`);
39
+ };
40
+ }
41
+
42
+ exports.buildSubscribeOperation = buildSubscribeOperation;
43
+ //# sourceMappingURL=subscribe-operation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscribe-operation.js","sources":["../../src/testing/subscribe-operation.ts"],"sourcesContent":["import type { TypedDocumentNode } from '@graphql-typed-document-node/core'\nimport { parse, subscribe, type DocumentNode, type ExecutionResult, type GraphQLSchema } from 'graphql'\nimport type { AnyGraphqlContext } from '../context'\n\nexport type VariableValues = { [key: string]: any }\n\nexport type TypedSubscribeRequest<TData = Record<string, unknown>, TVariables extends VariableValues = VariableValues> = {\n subscription: string | DocumentNode | TypedDocumentNode<TData, TVariables>\n variables?: TVariables\n operationName?: string\n}\n\n/**\n * Returns a `subscribeOperation` test helper for the provided schema and context creation function.\n *\n * The returned function runs a subscription operation directly against the schema using `graphql.subscribe`,\n * bypassing the websocket / `graphql-ws` transport. This gives tests complete control over JWT payloads\n * and other context inputs, the same way `buildExecuteOperation` does for queries and mutations in\n * `@makerx/graphql-apollo-server/testing`.\n *\n * The returned function:\n * - is strongly typed to the GraphQL context\n * - accepts `TypedDocumentNode` subscriptions to provide strong operation typing\n * - forwards any additional arguments to the supplied context creation function\n * - returns the subscription's `AsyncIterableIterator<ExecutionResult<TData>>` so tests can consume events via `for await` or manual `next()` calls\n * - throws if the subscription fails to produce an iterator (e.g. a validation error), surfacing the underlying errors\n *\n * @param schema The executable `GraphQLSchema`, or a factory that resolves one (useful when schema construction is async).\n * @param createContext A context creation function. Any args supplied after the request are forwarded to it,\n * so test fixtures can drive auth/user state per-call (e.g. `(jwtPayload?) => Promise<GraphQLContext>`).\n */\nexport function buildSubscribeOperation<TContext extends AnyGraphqlContext, TContextFunction extends (...args: any) => Promise<TContext>>(\n schema: GraphQLSchema | (() => Promise<GraphQLSchema>),\n createContext: TContextFunction,\n) {\n return async function subscribeOperation<TData = Record<string, unknown>, TVariables extends VariableValues = VariableValues>(\n { subscription, variables, operationName }: TypedSubscribeRequest<TData, TVariables>,\n ...createContextArgs: Parameters<TContextFunction>\n ): Promise<AsyncIterableIterator<ExecutionResult<TData>>> {\n const resolvedSchema = typeof schema === 'function' ? await schema() : schema\n const document = typeof subscription === 'string' ? parse(subscription) : subscription\n const contextValue = await createContext(...createContextArgs)\n const result = await subscribe({\n schema: resolvedSchema,\n document,\n contextValue,\n variableValues: variables as VariableValues | undefined,\n operationName,\n })\n if (Symbol.asyncIterator in result) return result as AsyncIterableIterator<ExecutionResult<TData>>\n throw new Error(`Subscription did not produce an iterator: ${JSON.stringify((result as ExecutionResult).errors)}`)\n }\n}\n"],"names":["parse","subscribe"],"mappings":";;;;AAYA;;;;;;;;;;;;;;;;;;AAkBG;AACG,SAAU,uBAAuB,CACrC,MAAsD,EACtD,aAA+B,EAAA;AAE/B,IAAA,OAAO,eAAe,kBAAkB,CACtC,EAAE,YAAY,EAAE,SAAS,EAAE,aAAa,EAA4C,EACpF,GAAG,iBAA+C,EAAA;AAElD,QAAA,MAAM,cAAc,GAAG,OAAO,MAAM,KAAK,UAAU,GAAG,MAAM,MAAM,EAAE,GAAG,MAAM;AAC7E,QAAA,MAAM,QAAQ,GAAG,OAAO,YAAY,KAAK,QAAQ,GAAGA,aAAK,CAAC,YAAY,CAAC,GAAG,YAAY;QACtF,MAAM,YAAY,GAAG,MAAM,aAAa,CAAC,GAAG,iBAAiB,CAAC;AAC9D,QAAA,MAAM,MAAM,GAAG,MAAMC,iBAAS,CAAC;AAC7B,YAAA,MAAM,EAAE,cAAc;YACtB,QAAQ;YACR,YAAY;AACZ,YAAA,cAAc,EAAE,SAAuC;YACvD,aAAa;AACd,SAAA,CAAC;AACF,QAAA,IAAI,MAAM,CAAC,aAAa,IAAI,MAAM;AAAE,YAAA,OAAO,MAAuD;AAClG,QAAA,MAAM,IAAI,KAAK,CAAC,CAAA,0CAAA,EAA6C,IAAI,CAAC,SAAS,CAAE,MAA0B,CAAC,MAAM,CAAC,CAAA,CAAE,CAAC;AACpH,IAAA,CAAC;AACH;;;;"}
@@ -0,0 +1,41 @@
1
+ import { parse, subscribe } from 'graphql';
2
+
3
+ /**
4
+ * Returns a `subscribeOperation` test helper for the provided schema and context creation function.
5
+ *
6
+ * The returned function runs a subscription operation directly against the schema using `graphql.subscribe`,
7
+ * bypassing the websocket / `graphql-ws` transport. This gives tests complete control over JWT payloads
8
+ * and other context inputs, the same way `buildExecuteOperation` does for queries and mutations in
9
+ * `@makerx/graphql-apollo-server/testing`.
10
+ *
11
+ * The returned function:
12
+ * - is strongly typed to the GraphQL context
13
+ * - accepts `TypedDocumentNode` subscriptions to provide strong operation typing
14
+ * - forwards any additional arguments to the supplied context creation function
15
+ * - returns the subscription's `AsyncIterableIterator<ExecutionResult<TData>>` so tests can consume events via `for await` or manual `next()` calls
16
+ * - throws if the subscription fails to produce an iterator (e.g. a validation error), surfacing the underlying errors
17
+ *
18
+ * @param schema The executable `GraphQLSchema`, or a factory that resolves one (useful when schema construction is async).
19
+ * @param createContext A context creation function. Any args supplied after the request are forwarded to it,
20
+ * so test fixtures can drive auth/user state per-call (e.g. `(jwtPayload?) => Promise<GraphQLContext>`).
21
+ */
22
+ function buildSubscribeOperation(schema, createContext) {
23
+ return async function subscribeOperation({ subscription, variables, operationName }, ...createContextArgs) {
24
+ const resolvedSchema = typeof schema === 'function' ? await schema() : schema;
25
+ const document = typeof subscription === 'string' ? parse(subscription) : subscription;
26
+ const contextValue = await createContext(...createContextArgs);
27
+ const result = await subscribe({
28
+ schema: resolvedSchema,
29
+ document,
30
+ contextValue,
31
+ variableValues: variables,
32
+ operationName,
33
+ });
34
+ if (Symbol.asyncIterator in result)
35
+ return result;
36
+ throw new Error(`Subscription did not produce an iterator: ${JSON.stringify(result.errors)}`);
37
+ };
38
+ }
39
+
40
+ export { buildSubscribeOperation };
41
+ //# sourceMappingURL=subscribe-operation.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscribe-operation.mjs","sources":["../../src/testing/subscribe-operation.ts"],"sourcesContent":["import type { TypedDocumentNode } from '@graphql-typed-document-node/core'\nimport { parse, subscribe, type DocumentNode, type ExecutionResult, type GraphQLSchema } from 'graphql'\nimport type { AnyGraphqlContext } from '../context'\n\nexport type VariableValues = { [key: string]: any }\n\nexport type TypedSubscribeRequest<TData = Record<string, unknown>, TVariables extends VariableValues = VariableValues> = {\n subscription: string | DocumentNode | TypedDocumentNode<TData, TVariables>\n variables?: TVariables\n operationName?: string\n}\n\n/**\n * Returns a `subscribeOperation` test helper for the provided schema and context creation function.\n *\n * The returned function runs a subscription operation directly against the schema using `graphql.subscribe`,\n * bypassing the websocket / `graphql-ws` transport. This gives tests complete control over JWT payloads\n * and other context inputs, the same way `buildExecuteOperation` does for queries and mutations in\n * `@makerx/graphql-apollo-server/testing`.\n *\n * The returned function:\n * - is strongly typed to the GraphQL context\n * - accepts `TypedDocumentNode` subscriptions to provide strong operation typing\n * - forwards any additional arguments to the supplied context creation function\n * - returns the subscription's `AsyncIterableIterator<ExecutionResult<TData>>` so tests can consume events via `for await` or manual `next()` calls\n * - throws if the subscription fails to produce an iterator (e.g. a validation error), surfacing the underlying errors\n *\n * @param schema The executable `GraphQLSchema`, or a factory that resolves one (useful when schema construction is async).\n * @param createContext A context creation function. Any args supplied after the request are forwarded to it,\n * so test fixtures can drive auth/user state per-call (e.g. `(jwtPayload?) => Promise<GraphQLContext>`).\n */\nexport function buildSubscribeOperation<TContext extends AnyGraphqlContext, TContextFunction extends (...args: any) => Promise<TContext>>(\n schema: GraphQLSchema | (() => Promise<GraphQLSchema>),\n createContext: TContextFunction,\n) {\n return async function subscribeOperation<TData = Record<string, unknown>, TVariables extends VariableValues = VariableValues>(\n { subscription, variables, operationName }: TypedSubscribeRequest<TData, TVariables>,\n ...createContextArgs: Parameters<TContextFunction>\n ): Promise<AsyncIterableIterator<ExecutionResult<TData>>> {\n const resolvedSchema = typeof schema === 'function' ? await schema() : schema\n const document = typeof subscription === 'string' ? parse(subscription) : subscription\n const contextValue = await createContext(...createContextArgs)\n const result = await subscribe({\n schema: resolvedSchema,\n document,\n contextValue,\n variableValues: variables as VariableValues | undefined,\n operationName,\n })\n if (Symbol.asyncIterator in result) return result as AsyncIterableIterator<ExecutionResult<TData>>\n throw new Error(`Subscription did not produce an iterator: ${JSON.stringify((result as ExecutionResult).errors)}`)\n }\n}\n"],"names":[],"mappings":";;AAYA;;;;;;;;;;;;;;;;;;AAkBG;AACG,SAAU,uBAAuB,CACrC,MAAsD,EACtD,aAA+B,EAAA;AAE/B,IAAA,OAAO,eAAe,kBAAkB,CACtC,EAAE,YAAY,EAAE,SAAS,EAAE,aAAa,EAA4C,EACpF,GAAG,iBAA+C,EAAA;AAElD,QAAA,MAAM,cAAc,GAAG,OAAO,MAAM,KAAK,UAAU,GAAG,MAAM,MAAM,EAAE,GAAG,MAAM;AAC7E,QAAA,MAAM,QAAQ,GAAG,OAAO,YAAY,KAAK,QAAQ,GAAG,KAAK,CAAC,YAAY,CAAC,GAAG,YAAY;QACtF,MAAM,YAAY,GAAG,MAAM,aAAa,CAAC,GAAG,iBAAiB,CAAC;AAC9D,QAAA,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC;AAC7B,YAAA,MAAM,EAAE,cAAc;YACtB,QAAQ;YACR,YAAY;AACZ,YAAA,cAAc,EAAE,SAAuC;YACvD,aAAa;AACd,SAAA,CAAC;AACF,QAAA,IAAI,MAAM,CAAC,aAAa,IAAI,MAAM;AAAE,YAAA,OAAO,MAAuD;AAClG,QAAA,MAAM,IAAI,KAAK,CAAC,CAAA,0CAAA,EAA6C,IAAI,CAAC,SAAS,CAAE,MAA0B,CAAC,MAAM,CAAC,CAAA,CAAE,CAAC;AACpH,IAAA,CAAC;AACH;;;;"}