@midnight-ntwrk/wallet-sdk-indexer-client 1.2.2 → 1.2.3

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
@@ -1,3 +1,10 @@
1
+ > [!IMPORTANT]
2
+ > **This package has moved.** The `@midnight-ntwrk` scope is published only
3
+ > during the migration window and will stop receiving updates. Please migrate to
4
+ > [`@midnightntwrk/wallet-sdk-indexer-client`](https://www.npmjs.com/package/@midnightntwrk/wallet-sdk-indexer-client).
5
+
6
+ ---
7
+
1
8
  # @midnight-ntwrk/wallet-sdk-indexer-client
2
9
 
3
10
  Client for communicating with the Midnight indexer service.
@@ -0,0 +1,97 @@
1
+ import { Stream } from 'effect';
2
+ /**
3
+ * Options controlling the bounded backpressure behavior.
4
+ *
5
+ * The implementation caps the number of in-flight (emitted but not yet consumed downstream) items. Once
6
+ * {@link bufferSize} is reached the underlying source is disposed; when the consumer drains back down to
7
+ * {@link resumeThreshold} a fresh subscription is opened with `variables(cursor)` for the running cursor.
8
+ *
9
+ * Emitted items are guaranteed strictly monotonic by {@link key}. Dedup uses a single exclusive watermark: any item with
10
+ * a key `<= watermark` is dropped, and each emitted key advances the watermark. The watermark seeds from {@link from},
11
+ * so the item _at_ `from` is treated as already-seen and dropped. This is what makes resume safe over an inclusive
12
+ * cursor: the boundary item a resume re-delivers has a key equal to the watermark and is dropped — without it, the
13
+ * first item after a resume would duplicate the last one emitted before the pause. A caller that needs the item at its
14
+ * logical cursor emitted (e.g. to let an already-caught-up consumer observe its tip) passes `from` one below that
15
+ * cursor.
16
+ *
17
+ * The cursor is the single source of truth for both initial open and resume — no separate "initial variables" field;
18
+ * `variables(from)` opens the first subscription, `variables(key(lastItem))` opens every subsequent one.
19
+ *
20
+ * No items above the watermark are dropped — the producer is paused, never thrown away.
21
+ *
22
+ * **Totality contract:** {@link key} and {@link variables} MUST be total — they must not throw for any input of their
23
+ * declared types. The wrapper does not catch exceptions from these closures: a throw from `key` propagates back through
24
+ * the source's sync callback (undefined behavior depending on the source's internals), and a throw from `variables`
25
+ * surfaces as an unhandled defect on the resulting `Stream` rather than a typed `E`. The signatures `(cursor: bigint)
26
+ * => V` and `(item: Item) => bigint` are the contract; honor them.
27
+ */
28
+ export interface BackpressureOptions<Item, V> {
29
+ /** Pause the underlying subscription once in-flight reaches this count. */
30
+ readonly bufferSize: number;
31
+ /**
32
+ * Resume once in-flight drains back to this count. Must be strictly less than `bufferSize` to give the consumer real
33
+ * hysteresis.
34
+ */
35
+ readonly resumeThreshold: number;
36
+ /** Initial cursor (bigint watermark). The first subscription opens with `variables(from)`. */
37
+ readonly from: bigint;
38
+ /** Derives subscription variables from a cursor. Used for the initial open and on every resume. */
39
+ readonly variables: (cursor: bigint) => V;
40
+ /** Monotonic key extractor — items with a key not strictly greater than the running watermark are dropped. */
41
+ readonly key: (result: Item) => bigint;
42
+ }
43
+ /**
44
+ * A push-based source. Each call opens a fresh session and returns a dispose handle. Items, errors and completion flow
45
+ * through the supplied callbacks.
46
+ *
47
+ * The source MUST stop delivering callbacks after `dispose()` is invoked. The caller pauses by disposing and resumes by
48
+ * calling the factory again with new variables; the source has no other backpressure signalling.
49
+ */
50
+ export type Source<Item, V, E> = (params: {
51
+ readonly variables: V;
52
+ readonly onItem: (item: Item) => void;
53
+ readonly onError: (error: E) => void;
54
+ readonly onComplete: () => void;
55
+ }) => () => void;
56
+ /** @internal — exported only for unit tests; not part of the public API. */
57
+ export type BPState = {
58
+ readonly paused: boolean;
59
+ readonly inFlight: number;
60
+ readonly lastKey: bigint;
61
+ readonly generation: number;
62
+ readonly terminal: boolean;
63
+ };
64
+ /** @internal */
65
+ export type ItemDecision = {
66
+ readonly emit: boolean;
67
+ readonly pause: boolean;
68
+ };
69
+ /** @internal */
70
+ export declare const initialBPState: (from: bigint) => BPState;
71
+ /**
72
+ * @internal Decide what to do with an item arriving on `onItem`.
73
+ *
74
+ * The watermark is exclusive throughout: an item is dropped iff its key is `<= lastKey`, and each emitted key advances
75
+ * the watermark. The watermark seeds from `from`, so the item at `from` itself is treated as already-seen and dropped —
76
+ * a caller that wants the item at its logical cursor emitted seeds `from` one below it. The same exclusive rule drops
77
+ * the boundary item an inclusive-cursor resume re-delivers (its key equals `lastKey`).
78
+ */
79
+ export declare const decideItem: (s: BPState, k: bigint, myGen: number, bufferSize: number) => readonly [ItemDecision, BPState];
80
+ /** @internal Decide whether an error callback should surface (false for stale/terminal). */
81
+ export declare const decideTerminate: (s: BPState, myGen: number) => readonly [boolean, BPState];
82
+ /** @internal Decide whether a completion is consumer-visible end-of-stream or dispose-induced. */
83
+ export declare const decideComplete: (s: BPState, myGen: number) => readonly [boolean, BPState];
84
+ /**
85
+ * @internal Decide whether to resume the source after a downstream consume. Paused implies we've emitted at least
86
+ * bufferSize items, so lastKey is meaningful. Returns true when the caller should open a new session — the caller
87
+ * derives the resume variables from `state.lastKey` itself.
88
+ */
89
+ export declare const decideResume: (s: BPState, resumeThreshold: number) => readonly [boolean, BPState];
90
+ /**
91
+ * Wrap a push-based {@link Source} with bounded backpressure. The source is paused (disposed) when the in-flight count
92
+ * reaches `bufferSize` and resumed (re-opened with `variables(lastKey)`) once the consumer drains back to
93
+ * `resumeThreshold`.
94
+ *
95
+ * See {@link BackpressureOptions} for the monotonic-key dedup invariant.
96
+ */
97
+ export declare const withBackpressure: <Item, V, E>(source: Source<Item, V, E>, options: BackpressureOptions<Item, V>) => Stream.Stream<Item, E>;
@@ -0,0 +1,146 @@
1
+ // This file is part of MIDNIGHT-WALLET-SDK.
2
+ // Copyright (C) Midnight Foundation
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // You may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+ //
14
+ // Bounded backpressure over a push-only source whose only knob is "dispose and
15
+ // re-open from a cursor". The classic shape for graphql-ws / SSE-style feeds
16
+ // that don't support pull or pause signalling.
17
+ //
18
+ // Composition:
19
+ // - Pure state machine (decideItem/decideTerminate/decideComplete/decideResume)
20
+ // contains all transition rules and is unit-testable in isolation.
21
+ // - `withBackpressure` wires the state machine to a {@link Source}, an
22
+ // internal Stream.asyncPush queue, and a Stream.tap drain detector.
23
+ // - Source authors translate their callbacks to the {@link Source} contract
24
+ // once; backpressure is independent of the underlying transport.
25
+ import { Effect, Stream, Ref } from 'effect';
26
+ /** @internal */
27
+ export const initialBPState = (from) => ({
28
+ paused: false,
29
+ inFlight: 0,
30
+ lastKey: from,
31
+ generation: 0,
32
+ terminal: false,
33
+ });
34
+ /**
35
+ * @internal Decide what to do with an item arriving on `onItem`.
36
+ *
37
+ * The watermark is exclusive throughout: an item is dropped iff its key is `<= lastKey`, and each emitted key advances
38
+ * the watermark. The watermark seeds from `from`, so the item at `from` itself is treated as already-seen and dropped —
39
+ * a caller that wants the item at its logical cursor emitted seeds `from` one below it. The same exclusive rule drops
40
+ * the boundary item an inclusive-cursor resume re-delivers (its key equals `lastKey`).
41
+ */
42
+ export const decideItem = (s, k, myGen, bufferSize) => {
43
+ if (s.terminal || s.generation !== myGen)
44
+ return [{ emit: false, pause: false }, s];
45
+ if (k <= s.lastKey)
46
+ return [{ emit: false, pause: false }, s];
47
+ const inFlight = s.inFlight + 1;
48
+ const pause = inFlight >= bufferSize && !s.paused;
49
+ return [
50
+ { emit: true, pause },
51
+ { ...s, inFlight, lastKey: k, paused: s.paused || pause },
52
+ ];
53
+ };
54
+ /** @internal Decide whether an error callback should surface (false for stale/terminal). */
55
+ export const decideTerminate = (s, myGen) => s.terminal || s.generation !== myGen ? [false, s] : [true, { ...s, terminal: true }];
56
+ /** @internal Decide whether a completion is consumer-visible end-of-stream or dispose-induced. */
57
+ export const decideComplete = (s, myGen) => {
58
+ if (s.paused || s.terminal || s.generation !== myGen)
59
+ return [false, s];
60
+ return [true, { ...s, terminal: true }];
61
+ };
62
+ /**
63
+ * @internal Decide whether to resume the source after a downstream consume. Paused implies we've emitted at least
64
+ * bufferSize items, so lastKey is meaningful. Returns true when the caller should open a new session — the caller
65
+ * derives the resume variables from `state.lastKey` itself.
66
+ */
67
+ export const decideResume = (s, resumeThreshold) => {
68
+ if (s.terminal)
69
+ return [false, s];
70
+ const inFlight = Math.max(0, s.inFlight - 1);
71
+ if (s.paused && inFlight <= resumeThreshold) {
72
+ return [true, { ...s, inFlight, paused: false, generation: s.generation + 1 }];
73
+ }
74
+ return [false, { ...s, inFlight }];
75
+ };
76
+ // ============================================================================
77
+ // Stream wrapper
78
+ // ============================================================================
79
+ /**
80
+ * Wrap a push-based {@link Source} with bounded backpressure. The source is paused (disposed) when the in-flight count
81
+ * reaches `bufferSize` and resumed (re-opened with `variables(lastKey)`) once the consumer drains back to
82
+ * `resumeThreshold`.
83
+ *
84
+ * See {@link BackpressureOptions} for the monotonic-key dedup invariant.
85
+ */
86
+ export const withBackpressure = (source, options) => {
87
+ const { bufferSize, resumeThreshold, key, from, variables } = options;
88
+ // Stream.unwrap re-runs the factory per subscription, so each consumer of
89
+ // the returned Stream gets its own Refs and its own source session.
90
+ return Stream.unwrap(Effect.gen(function* () {
91
+ // Three refs cover the inherently mutable bridge between sync source
92
+ // callbacks and Effect: the state machine, the current dispose handle,
93
+ // and the asyncPush emit handle (only bound while a consumer is
94
+ // attached).
95
+ const stateRef = yield* Ref.make(initialBPState(from));
96
+ const disposerRef = yield* Ref.make(null);
97
+ const emitRef = yield* Ref.make(null);
98
+ // Source callbacks are sync JS. Ref ops are sync internally, so runSync
99
+ // is safe here. Confined to these two helpers for auditability.
100
+ const modify = (f) => Effect.runSync(Ref.modify(stateRef, f));
101
+ const takeDisposer = () => Effect.runSync(Ref.getAndSet(disposerRef, null));
102
+ const openSession = () => Effect.gen(function* () {
103
+ const state = yield* Ref.get(stateRef);
104
+ if (state.terminal)
105
+ return;
106
+ const emit = yield* Ref.get(emitRef);
107
+ if (emit === null)
108
+ return;
109
+ const myGen = state.generation;
110
+ const dispose = source({
111
+ variables: variables(state.lastKey),
112
+ onItem: (item) => {
113
+ const action = modify((s) => decideItem(s, key(item), myGen, bufferSize));
114
+ if (!action.emit)
115
+ return;
116
+ emit.single(item);
117
+ if (action.pause)
118
+ takeDisposer()?.();
119
+ },
120
+ onError: (error) => {
121
+ if (modify((s) => decideTerminate(s, myGen)))
122
+ emit.fail(error);
123
+ },
124
+ onComplete: () => {
125
+ if (modify((s) => decideComplete(s, myGen)))
126
+ emit.end();
127
+ },
128
+ });
129
+ yield* Ref.set(disposerRef, dispose);
130
+ });
131
+ return Stream.asyncPush((emit) => Effect.acquireRelease(Effect.gen(function* () {
132
+ yield* Ref.set(emitRef, emit);
133
+ yield* openSession();
134
+ }), () => Effect.gen(function* () {
135
+ yield* Ref.update(stateRef, (s) => ({ ...s, terminal: true }));
136
+ const d = yield* Ref.getAndSet(disposerRef, null);
137
+ if (d !== null)
138
+ yield* Effect.sync(() => d());
139
+ yield* Ref.set(emitRef, null);
140
+ })), { bufferSize: 'unbounded' }).pipe(Stream.tap(() => Effect.gen(function* () {
141
+ const shouldResume = yield* Ref.modify(stateRef, (s) => decideResume(s, resumeThreshold));
142
+ if (shouldResume)
143
+ yield* openSession();
144
+ })));
145
+ }));
146
+ };
@@ -6,6 +6,12 @@ import type { Query } from './Query.js';
6
6
  export interface Subscription<R, V, F extends Subscription.SubscriptionFn<R, V> = Subscription.SubscriptionFn<R, V>> extends Effect.Effect<F> {
7
7
  readonly tag: Context.Tag<Subscription<R, V>, F>;
8
8
  readonly run: F;
9
+ /**
10
+ * Like {@link run}, but caps the in-flight item count by disposing the underlying GraphQL subscription when the
11
+ * consumer can't keep up, and re-opens it with `variables(cursor)` for the running monotonic cursor once the queue
12
+ * drains. Items are never dropped.
13
+ */
14
+ readonly runWithBackpressure: (options: SubscriptionClient.BackpressureOptions<R, V>) => Stream.Stream<R, ClientError | ServerError, SubscriptionClient>;
9
15
  }
10
16
  export declare namespace Subscription {
11
17
  /**
@@ -34,6 +34,22 @@ class SubscriptionImpl extends Effectable.Class {
34
34
  });
35
35
  });
36
36
  }
37
+ runWithBackpressure(options) {
38
+ const self = this; // eslint-disable-line @typescript-eslint/no-this-alias
39
+ // Mirror the `run`/`commit` dispatch: if a caller has provided an override
40
+ // via `self.tag` (test mocks do this), fall back to it — those overrides
41
+ // can't speak backpressure, but tests don't need to. The override is
42
+ // invoked with the initial variables derived from the cursor, since the
43
+ // override doesn't know the cursor protocol. Without an override, take the
44
+ // bounded pause/resume path against the underlying client.
45
+ return Stream.unwrap(Effect.gen(function* () {
46
+ const tagged = yield* Effect.serviceOption(self.tag);
47
+ return Option.match(tagged, {
48
+ onSome: (fn) => fn(options.variables(options.from)),
49
+ onNone: () => SubscriptionClient.pipe(Stream.flatMap((client) => client.subscribeWithBackpressure(self.document, options))),
50
+ });
51
+ }));
52
+ }
37
53
  defaultFn(variables) {
38
54
  return SubscriptionClient.pipe(Stream.flatMap((client) => client.subscribe(this.document, variables)));
39
55
  }
@@ -1,5 +1,6 @@
1
1
  import { type Stream, Context } from 'effect';
2
2
  import type { Query } from './Query.js';
3
+ import type { BackpressureOptions as BackpressureOptionsImpl } from './Backpressure.js';
3
4
  import { type ClientError, type ServerError } from '@midnight-ntwrk/wallet-sdk-utilities/networking';
4
5
  declare const SubscriptionClient_base: Context.TagClass<SubscriptionClient, "@midnight-ntwrk/indexer-client#SubscriptionClient", SubscriptionClient.Service>;
5
6
  export declare class SubscriptionClient extends SubscriptionClient_base {
@@ -9,8 +10,14 @@ export declare namespace SubscriptionClient {
9
10
  readonly url: URL | string;
10
11
  readonly keepAlive?: number | undefined;
11
12
  }
13
+ /**
14
+ * Options for {@link Service.subscribeWithBackpressure}. Canonical definition lives in
15
+ * {@link ./Backpressure.BackpressureOptions} — re-exported here for ergonomic access through the namespace.
16
+ */
17
+ type BackpressureOptions<R, V> = BackpressureOptionsImpl<R, V>;
12
18
  interface Service {
13
19
  subscribe<R, V, T extends Query.Document<R, V> = Query.Document<R, V>>(document: T, variables: V): Stream.Stream<Query.Result<T>, ClientError | ServerError>;
20
+ subscribeWithBackpressure<R, V, T extends Query.Document<R, V> = Query.Document<R, V>>(document: T, options: BackpressureOptions<Query.Result<T>, V>): Stream.Stream<Query.Result<T>, ClientError | ServerError>;
14
21
  }
15
22
  }
16
23
  export {};
@@ -10,11 +10,30 @@
10
10
  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
11
  // See the License for the specific language governing permissions and
12
12
  // limitations under the License.
13
- import { Effect, Stream, Layer } from 'effect';
13
+ import { Effect, Stream, Layer, Array as Arr } from 'effect';
14
14
  import { createClient } from 'graphql-ws';
15
15
  import { print } from 'graphql';
16
16
  import { SubscriptionClient } from './SubscriptionClient.js';
17
+ import { withBackpressure } from './Backpressure.js';
17
18
  import { WsURL, ClientError, ServerError, } from '@midnight-ntwrk/wallet-sdk-utilities/networking';
19
+ // ============================================================================
20
+ // Error classification
21
+ // ============================================================================
22
+ // graphql-ws delivers a subscription sink error as one of: `Error`, `CloseEvent`,
23
+ // or `readonly GraphQLError[]` (per its Sink docs). Only the array form should
24
+ // surface as a ClientError; everything else is a transport/server failure.
25
+ const isGraphQLErrorArray = (err) => {
26
+ if (!Arr.isArray(err) || err.length === 0)
27
+ return false;
28
+ const first = err[0];
29
+ return typeof first === 'object' && first !== null && 'message' in first && typeof first.message === 'string';
30
+ };
31
+ const toClientOrServerError = (err) => isGraphQLErrorArray(err)
32
+ ? new ClientError({ message: err.map((e) => e.message).join('; '), cause: err })
33
+ : new ServerError({ message: String(err) });
34
+ // ============================================================================
35
+ // Layer / class
36
+ // ============================================================================
18
37
  export const layer = (config) => Layer.scoped(SubscriptionClient, WsURL.make(config.url).pipe(Effect.flatMap((url) => Effect.acquireRelease(Effect.sync(() => createClient({ url: url.toString(), shouldRetry: () => false, keepAlive: config.keepAlive ?? 15_000 })), (client) => Effect.sync(() => client.dispose()))), Effect.map((client) => new WebSocketSubscriptionClientImpl(client))));
19
38
  class WebSocketSubscriptionClientImpl {
20
39
  constructor(client) {
@@ -22,23 +41,49 @@ class WebSocketSubscriptionClientImpl {
22
41
  }
23
42
  client;
24
43
  subscribe(document, variables) {
25
- return Stream.async((emit) => {
26
- const dispose = this.client.subscribe({ query: print(document), variables: variables }, {
27
- next: (data) => {
28
- if (data.errors) {
29
- return void emit.fail(new ClientError({ message: data.errors[0].message, cause: data.errors }));
30
- }
31
- void emit.single(data.data);
32
- },
33
- error: (err) => {
34
- void emit.fail(Array.isArray(err)
35
- ? new ClientError({ message: err[0].message })
36
- : new ServerError({ message: String(err) }));
37
- },
38
- complete: () => void emit.end(),
39
- });
40
- // Ensure we dispose of the query if the running Fiber is terminated.
41
- return Effect.sync(dispose);
44
+ // Stream.async forks a top-level fiber per emit.single (via
45
+ // Runtime.runPromiseExit), which leaks into Effect's Global.roots at the
46
+ // ~1k msg/sec rate the indexer pushes. Stream.asyncPush writes straight
47
+ // to an internal queue and tears it down with the surrounding scope.
48
+ return Stream.asyncPush((emit) => Effect.acquireRelease(Effect.sync(() => this.client.subscribe({ query: print(document), variables: variables }, {
49
+ next: (data) => {
50
+ if (data.errors) {
51
+ emit.fail(new ClientError({
52
+ message: data.errors.map((e) => e.message).join('; '),
53
+ cause: data.errors,
54
+ }));
55
+ }
56
+ else {
57
+ emit.single(data.data);
58
+ }
59
+ },
60
+ error: (err) => {
61
+ if (isGraphQLErrorArray(err)) {
62
+ emit.fail(new ClientError({ message: err.map((e) => e.message).join('; '), cause: err }));
63
+ }
64
+ else {
65
+ emit.fail(new ServerError({ message: String(err) }));
66
+ }
67
+ },
68
+ complete: () => {
69
+ emit.end();
70
+ },
71
+ })), (dispose) => Effect.sync(() => dispose())), { bufferSize: 'unbounded' });
72
+ }
73
+ subscribeWithBackpressure(document, options) {
74
+ const client = this.client;
75
+ // Adapt graphql-ws's Sink callbacks to the generic Source contract; the
76
+ // backpressure/lifecycle logic lives in `withBackpressure`.
77
+ const source = ({ variables, onItem, onError, onComplete }) => client.subscribe({ query: print(document), variables: variables }, {
78
+ next: (data) => {
79
+ if (data.errors)
80
+ onError(toClientOrServerError(data.errors));
81
+ else
82
+ onItem(data.data);
83
+ },
84
+ error: (err) => onError(toClientOrServerError(err)),
85
+ complete: onComplete,
42
86
  });
87
+ return withBackpressure(source, options);
43
88
  }
44
89
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,146 @@
1
+ // This file is part of MIDNIGHT-WALLET-SDK.
2
+ // Copyright (C) Midnight Foundation
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // You may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+ import { describe, it, expect } from 'vitest';
14
+ import { initialBPState, decideItem, decideTerminate, decideComplete, decideResume, } from '../Backpressure.js';
15
+ const state = (overrides = {}) => ({
16
+ ...initialBPState(0n),
17
+ ...overrides,
18
+ });
19
+ describe('Backpressure state machine', () => {
20
+ describe('decideItem', () => {
21
+ it('emits and advances watermark when key is strictly greater than lastKey', () => {
22
+ const [decision, next] = decideItem(state({ lastKey: 5n }), 6n, 0, 1000);
23
+ expect(decision).toEqual({ emit: true, pause: false });
24
+ expect(next.lastKey).toBe(6n);
25
+ expect(next.inFlight).toBe(1);
26
+ });
27
+ it('drops the item at `from` on the initial session (exclusive watermark)', () => {
28
+ // The watermark seeds from `from`, so the item whose key equals `from` is treated as
29
+ // already-seen. A caller that wants the item at its logical cursor emitted seeds `from` one
30
+ // below it (e.g. a wallet passes `appliedIndex - 1` to still receive the boundary event).
31
+ const before = initialBPState(5n);
32
+ const [decision, next] = decideItem(before, 5n, 0, 1000);
33
+ expect(decision.emit).toBe(false);
34
+ expect(next).toBe(before);
35
+ });
36
+ it('drops a re-delivered boundary equal to lastKey (inclusive-cursor dedup)', () => {
37
+ // An inclusive resume cursor re-delivers the last-emitted key; the exclusive watermark drops
38
+ // it so it is not emitted twice.
39
+ const before = state({ lastKey: 5n, inFlight: 0 });
40
+ const [decision, next] = decideItem(before, 5n, 0, 1000);
41
+ expect(decision.emit).toBe(false);
42
+ expect(next).toBe(before);
43
+ });
44
+ it('drops items below lastKey', () => {
45
+ const before = state({ lastKey: 5n });
46
+ const [decision, next] = decideItem(before, 3n, 0, 1000);
47
+ expect(decision.emit).toBe(false);
48
+ expect(next).toBe(before);
49
+ });
50
+ it('drops items from a stale generation', () => {
51
+ const before = state({ generation: 2 });
52
+ const [decision, next] = decideItem(before, 1n, 1, 1000);
53
+ expect(decision.emit).toBe(false);
54
+ expect(next).toBe(before);
55
+ });
56
+ it('drops items when terminal', () => {
57
+ const before = state({ terminal: true });
58
+ const [decision, next] = decideItem(before, 1n, 0, 1000);
59
+ expect(decision.emit).toBe(false);
60
+ expect(next).toBe(before);
61
+ });
62
+ it('sets pause when inFlight reaches bufferSize', () => {
63
+ const [decision, next] = decideItem(state({ lastKey: 5n, inFlight: 2 }), 6n, 0, 3);
64
+ expect(decision).toEqual({ emit: true, pause: true });
65
+ expect(next.paused).toBe(true);
66
+ expect(next.inFlight).toBe(3);
67
+ });
68
+ it('does not re-pause when already paused', () => {
69
+ const [decision, next] = decideItem(state({ lastKey: 5n, inFlight: 3, paused: true }), 6n, 0, 3);
70
+ expect(decision).toEqual({ emit: true, pause: false });
71
+ expect(next.paused).toBe(true);
72
+ });
73
+ });
74
+ describe('decideTerminate', () => {
75
+ it('surfaces a terminal event and marks state terminal', () => {
76
+ const [shouldSurface, next] = decideTerminate(state(), 0);
77
+ expect(shouldSurface).toBe(true);
78
+ expect(next.terminal).toBe(true);
79
+ });
80
+ it('swallows terminal events from a stale generation', () => {
81
+ const before = state({ generation: 2 });
82
+ const [shouldSurface, next] = decideTerminate(before, 1);
83
+ expect(shouldSurface).toBe(false);
84
+ expect(next).toBe(before);
85
+ });
86
+ it('is idempotent — already-terminal state returns false', () => {
87
+ const before = state({ terminal: true });
88
+ const [shouldSurface, next] = decideTerminate(before, 0);
89
+ expect(shouldSurface).toBe(false);
90
+ expect(next).toBe(before);
91
+ });
92
+ });
93
+ describe('decideComplete', () => {
94
+ it('signals end-of-stream when not paused, stale, or terminal', () => {
95
+ const [shouldEnd, next] = decideComplete(state(), 0);
96
+ expect(shouldEnd).toBe(true);
97
+ expect(next.terminal).toBe(true);
98
+ });
99
+ it('treats paused completion as dispose-induced (no end)', () => {
100
+ // This is the regression case: without the paused check, a dispose-on-pause
101
+ // would deliver a `complete:` from the old session and terminate the
102
+ // consumer-visible stream mid-resume.
103
+ const before = state({ paused: true });
104
+ const [shouldEnd, next] = decideComplete(before, 0);
105
+ expect(shouldEnd).toBe(false);
106
+ expect(next).toBe(before);
107
+ });
108
+ it('swallows completions from a stale generation', () => {
109
+ const before = state({ generation: 2 });
110
+ const [shouldEnd, next] = decideComplete(before, 1);
111
+ expect(shouldEnd).toBe(false);
112
+ expect(next).toBe(before);
113
+ });
114
+ });
115
+ describe('decideResume', () => {
116
+ it('resumes and bumps generation when paused and drained to threshold', () => {
117
+ const [shouldResume, next] = decideResume(state({ paused: true, inFlight: 3, generation: 4 }), 2);
118
+ expect(shouldResume).toBe(true);
119
+ expect(next).toMatchObject({ paused: false, inFlight: 2, generation: 5 });
120
+ });
121
+ it('decrements inFlight even when not resuming (drain accounting)', () => {
122
+ const [shouldResume, next] = decideResume(state({ paused: false, inFlight: 7 }), 2);
123
+ expect(shouldResume).toBe(false);
124
+ expect(next.inFlight).toBe(6);
125
+ });
126
+ it('does not resume when above threshold', () => {
127
+ const [shouldResume, next] = decideResume(state({ paused: true, inFlight: 5 }), 2);
128
+ expect(shouldResume).toBe(false);
129
+ expect(next).toMatchObject({ paused: true, inFlight: 4 });
130
+ });
131
+ it('does not resume when terminal', () => {
132
+ const before = state({ paused: true, inFlight: 3, terminal: true });
133
+ const [shouldResume, next] = decideResume(before, 2);
134
+ expect(shouldResume).toBe(false);
135
+ expect(next).toBe(before);
136
+ });
137
+ it('clamps inFlight at zero (defensive against drain underflow)', () => {
138
+ const [, next] = decideResume(state({ inFlight: 0 }), 2);
139
+ expect(next.inFlight).toBe(0);
140
+ });
141
+ it('does not resume when not paused, even if drained', () => {
142
+ const [shouldResume] = decideResume(state({ paused: false, inFlight: 0 }), 2);
143
+ expect(shouldResume).toBe(false);
144
+ });
145
+ });
146
+ });
package/package.json CHANGED
@@ -1,20 +1,22 @@
1
1
  {
2
2
  "name": "@midnight-ntwrk/wallet-sdk-indexer-client",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "type": "module",
5
5
  "module": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "author": "Midnight Foundation",
8
8
  "license": "Apache-2.0",
9
9
  "publishConfig": {
10
- "registry": "https://npm.pkg.github.com/"
10
+ "registry": "https://registry.npmjs.org/",
11
+ "access": "public"
11
12
  },
12
13
  "files": [
13
14
  "dist/"
14
15
  ],
15
16
  "repository": {
16
17
  "type": "git",
17
- "url": "git+https://github.com/midnight-ntwrk/artifacts.git"
18
+ "url": "git+https://github.com/midnightntwrk/midnight-wallet.git",
19
+ "directory": "packages/indexer-client"
18
20
  },
19
21
  "exports": {
20
22
  ".": {
@@ -35,10 +37,10 @@
35
37
  "graphql-ws": "^6.0.7"
36
38
  },
37
39
  "devDependencies": {
38
- "@graphql-codegen/cli": "^7.0.0",
39
- "@graphql-codegen/client-preset": "^6.0.0",
40
- "@graphql-codegen/typescript": "^6.0.0",
41
- "@graphql-codegen/typescript-operations": "^6.0.0"
40
+ "@graphql-codegen/cli": "7.1.2",
41
+ "@graphql-codegen/client-preset": "6.0.1",
42
+ "@graphql-codegen/typescript": "6.0.2",
43
+ "@graphql-codegen/typescript-operations": "6.0.3"
42
44
  },
43
45
  "scripts": {
44
46
  "gql:codegen": "graphql-codegen --clean --config codegen.ts",