@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 +7 -0
- package/dist/effect/Backpressure.d.ts +97 -0
- package/dist/effect/Backpressure.js +146 -0
- package/dist/effect/Subscription.d.ts +6 -0
- package/dist/effect/Subscription.js +16 -0
- package/dist/effect/SubscriptionClient.d.ts +7 -0
- package/dist/effect/WsSubscriptionClient.js +63 -18
- package/dist/effect/test/backpressure.test.d.ts +1 -0
- package/dist/effect/test/backpressure.test.js +146 -0
- package/package.json +9 -7
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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.
|
|
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://
|
|
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-
|
|
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": "
|
|
39
|
-
"@graphql-codegen/client-preset": "
|
|
40
|
-
"@graphql-codegen/typescript": "
|
|
41
|
-
"@graphql-codegen/typescript-operations": "
|
|
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",
|