@setluca/mcp 0.2.0 → 0.3.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/CHANGELOG.md +426 -0
- package/LICENSE +21 -0
- package/README.md +19 -258
- package/dist/annotations.js +35 -20
- package/dist/client-address.js +64 -0
- package/dist/completions.js +44 -27
- package/dist/config.js +89 -38
- package/dist/errors.js +99 -22
- package/dist/fields.js +68 -0
- package/dist/fixed-window.js +46 -0
- package/dist/generated/openapi-inputs.js +216 -5820
- package/dist/generated/openapi-outputs.js +19014 -14001
- package/dist/generated/route-catalog.js +80 -0
- package/dist/http.js +108 -44
- package/dist/index.js +4 -1
- package/dist/oauth-challenge.js +74 -0
- package/dist/oauth-resolver.js +58 -61
- package/dist/observability.js +31 -16
- package/dist/openapi-schema.js +175 -0
- package/dist/operations/catalog.js +17 -0
- package/dist/operations/groups/analytics.js +20 -10
- package/dist/operations/groups/bookings.js +43 -15
- package/dist/operations/groups/broadcasts.js +29 -58
- package/dist/operations/groups/cadences.js +3 -1
- package/dist/operations/groups/callEvents.js +19 -72
- package/dist/operations/groups/campaigns.js +28 -32
- package/dist/operations/groups/channels.js +23 -0
- package/dist/operations/groups/conversations.js +14 -25
- package/dist/operations/groups/insights.js +4 -0
- package/dist/operations/groups/integrations.js +15 -17
- package/dist/operations/groups/knowledge.js +5 -1
- package/dist/operations/groups/leads.js +22 -23
- package/dist/operations/groups/learning.js +13 -1
- package/dist/operations/groups/memory.js +4 -6
- package/dist/operations/groups/reports.js +1 -1
- package/dist/operations/groups/reviewQueue.js +17 -14
- package/dist/operations/groups/safety.js +1 -1
- package/dist/operations/groups/voice.js +0 -1
- package/dist/operations/groups/webhooks.js +16 -4
- package/dist/operations/manifest.js +9 -8
- package/dist/operations/registry.js +110 -116
- package/dist/operations.js +6 -5
- package/dist/optional-field.js +15 -3
- package/dist/page-contract.js +74 -0
- package/dist/pagination.js +149 -68
- package/dist/prompts.js +139 -88
- package/dist/provenance.js +2 -2
- package/dist/rate-limit.js +40 -0
- package/dist/remote.js +145 -65
- package/dist/resilience.js +120 -55
- package/dist/resolvers.js +125 -0
- package/dist/resources.js +17 -2
- package/dist/scopes.js +207 -0
- package/dist/serialization.js +32 -22
- package/dist/server.js +174 -191
- package/dist/standard-schema.js +215 -0
- package/dist/task-tools/analytics.js +145 -86
- package/dist/task-tools/bookings.js +176 -86
- package/dist/task-tools/cadences.js +30 -45
- package/dist/task-tools/calls.js +132 -125
- package/dist/task-tools/inbox.js +89 -53
- package/dist/task-tools/leads.js +49 -77
- package/dist/task-tools/runtime.js +80 -20
- package/dist/task-tools.js +1 -1
- package/dist/token-verification.js +42 -0
- package/dist/tool-cache.js +3 -2
- package/dist/tool.js +155 -100
- package/dist/version.js +1 -1
- package/package.json +40 -18
- package/dist/openapi-zod.js +0 -333
- package/dist/page-shape.js +0 -47
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as HashMap from "effect/HashMap";
|
|
2
|
+
import * as Option from "effect/Option";
|
|
3
|
+
import * as R from "effect/Record";
|
|
4
|
+
import { OPENAPI_CURSOR_QUERY_PARAMS } from "./generated/openapi-inputs.js";
|
|
5
|
+
import { OPENAPI_OUTPUTS } from "./generated/openapi-outputs.js";
|
|
6
|
+
const CURSOR_QUERY_PARAMS_BY_ROUTE = HashMap.fromIterable(R.toEntries(OPENAPI_CURSOR_QUERY_PARAMS));
|
|
7
|
+
const OUTPUTS_BY_ROUTE = HashMap.fromIterable(R.toEntries(OPENAPI_OUTPUTS));
|
|
8
|
+
/**
|
|
9
|
+
* The records field for a page whose body declares more than one array. Every
|
|
10
|
+
* other paging route declares exactly one, so it needs no entry here.
|
|
11
|
+
*/
|
|
12
|
+
// oxlint-disable-next-line anti-slop/no-known-value-widening -- looked up by a route string built at runtime, so the keys must stay open
|
|
13
|
+
const ITEMS_KEY_BY_ROUTE = {
|
|
14
|
+
// `availableChannels` is the filter's option list, not a page of records.
|
|
15
|
+
"GET /api/conversations": "conversations",
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The array field a page carries its records in, absent when the body hands
|
|
19
|
+
* back no cursor or declares no array.
|
|
20
|
+
*
|
|
21
|
+
* A body with one array has an obvious answer. A body with more than one needs
|
|
22
|
+
* `declared` to name it: guessing by declaration order would start merging the
|
|
23
|
+
* wrong array the day the API adds a field above the records. An undeclared
|
|
24
|
+
* tie, or a declared key the body does not carry as an array, throws while the
|
|
25
|
+
* catalog is built, so the mistake fails every test instead of reaching a
|
|
26
|
+
* client.
|
|
27
|
+
*/
|
|
28
|
+
export function itemsKeyOf(body, declared) {
|
|
29
|
+
if (body?.type !== "object" || body.properties?.nextCursor === undefined) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const arrays = R.toEntries(body.properties)
|
|
33
|
+
.filter(([key, schema]) => key !== "nextCursor" && schema?.type === "array")
|
|
34
|
+
.map(([key]) => key);
|
|
35
|
+
if (declared !== undefined) {
|
|
36
|
+
if (!arrays.includes(declared)) {
|
|
37
|
+
// oxlint-disable-next-line effect/avoid-untagged-errors -- a stale catalog entry is a programming error, not a runtime failure
|
|
38
|
+
throw new Error(`Page items key "${declared}" is not an array field of this response (arrays: ${arrays.join(", ") || "none"}).`);
|
|
39
|
+
}
|
|
40
|
+
return declared;
|
|
41
|
+
}
|
|
42
|
+
if (arrays.length > 1) {
|
|
43
|
+
// oxlint-disable-next-line effect/avoid-untagged-errors -- an ambiguous page shape is a programming error, not a runtime failure
|
|
44
|
+
throw new Error(`A paged response declares several arrays (${arrays.join(", ")}). Name its records field in ITEMS_KEY_BY_ROUTE.`);
|
|
45
|
+
}
|
|
46
|
+
return arrays[0];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The page shape of a route, absent when the route does not paginate.
|
|
50
|
+
*
|
|
51
|
+
* Walking a list needs both halves: a body that hands back a cursor and an
|
|
52
|
+
* array to merge, and a query field to send the cursor in. Guessing from the
|
|
53
|
+
* operation id used to answer this, and was wrong fourteen times: twelve routes
|
|
54
|
+
* that answer with a plain array and no cursor, and two that paginate under an
|
|
55
|
+
* id that does not end in `.list`.
|
|
56
|
+
*/
|
|
57
|
+
export function pageContractFor(method, path) {
|
|
58
|
+
const route = `${method} ${path}`;
|
|
59
|
+
return pageContractOf(Option.getOrUndefined(HashMap.get(CURSOR_QUERY_PARAMS_BY_ROUTE, route)), Option.getOrUndefined(HashMap.get(OUTPUTS_BY_ROUTE, route)), ITEMS_KEY_BY_ROUTE[route]);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The page shape a cursor query field and a response schema describe, absent
|
|
63
|
+
* when either half is missing. Split from {@link pageContractFor} so both halves can be
|
|
64
|
+
* withheld independently: the manifest happens to carry no route that takes a
|
|
65
|
+
* cursor and answers without one, and that asymmetry is a fact about today's
|
|
66
|
+
* API rather than a rule this function may assume.
|
|
67
|
+
*/
|
|
68
|
+
export function pageContractOf(cursorQueryParam, body, declaredItemsKey) {
|
|
69
|
+
const itemsKey = itemsKeyOf(body, declaredItemsKey);
|
|
70
|
+
if (cursorQueryParam === undefined || itemsKey === undefined) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
return { cursorQueryParam, itemsKey };
|
|
74
|
+
}
|
package/dist/pagination.js
CHANGED
|
@@ -1,127 +1,195 @@
|
|
|
1
|
-
import
|
|
2
|
-
import * as
|
|
1
|
+
import * as Arr from "effect/Array";
|
|
2
|
+
import * as Duration from "effect/Duration";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as HashSet from "effect/HashSet";
|
|
5
|
+
import * as Option from "effect/Option";
|
|
6
|
+
import * as R from "effect/Record";
|
|
7
|
+
import * as Ref from "effect/Ref";
|
|
8
|
+
import * as Result from "effect/Result";
|
|
9
|
+
import * as Schema from "effect/Schema";
|
|
10
|
+
import { formatError, isAuthorizationFailure, } from "./errors.js";
|
|
11
|
+
import { NonEmptyString } from "./fields.js";
|
|
3
12
|
import { LucaApi } from "./http.js";
|
|
4
13
|
import { logAgentSurfaceEvent } from "./observability.js";
|
|
5
14
|
import { DEFAULT_MAX_PAGES, } from "./operations.js";
|
|
6
|
-
import {
|
|
7
|
-
|
|
15
|
+
import { optionalField } from "./optional-field.js";
|
|
16
|
+
import { isJsonObject, JsonValue } from "./serialization.js";
|
|
17
|
+
const decodeItems = Schema.decodeUnknownOption(Schema.Array(JsonValue));
|
|
18
|
+
const isCursor = Schema.is(NonEmptyString);
|
|
8
19
|
function pageItems(page, itemsKey) {
|
|
9
|
-
|
|
10
|
-
return parsed.success ? parsed.data : [];
|
|
20
|
+
return Option.getOrElse(decodeItems(page[itemsKey]), () => []);
|
|
11
21
|
}
|
|
12
22
|
/**
|
|
13
|
-
* The page as something the walk can
|
|
14
|
-
* object or
|
|
15
|
-
*
|
|
23
|
+
* The page as something the walk can merge, or nothing when the body is not an
|
|
24
|
+
* object or does not carry the array the route's page shape names. Merging a
|
|
25
|
+
* shape the paginator cannot read would corrupt it.
|
|
16
26
|
*/
|
|
17
|
-
function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
const nextCursor = parsed.data.nextCursor;
|
|
23
|
-
if (nextCursor === undefined) {
|
|
24
|
-
return Option.none();
|
|
25
|
-
}
|
|
26
|
-
return Option.some({ ...parsed.data, nextCursor });
|
|
27
|
+
function readablePage(value, itemsKey) {
|
|
28
|
+
return isJsonObject(value) && Array.isArray(value[itemsKey])
|
|
29
|
+
? Option.some(value)
|
|
30
|
+
: Option.none();
|
|
27
31
|
}
|
|
28
32
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* aggregating a shape the paginator cannot read would corrupt it.
|
|
33
|
+
* The cursor to the page after this one. A page that leaves `nextCursor` out,
|
|
34
|
+
* or sends it null or empty, is the last page.
|
|
32
35
|
*/
|
|
33
|
-
function carriesItems(page, itemsKey) {
|
|
34
|
-
return Array.isArray(page[itemsKey]);
|
|
35
|
-
}
|
|
36
36
|
function cursorOf(page) {
|
|
37
|
-
const
|
|
38
|
-
return
|
|
37
|
+
const cursor = page.nextCursor;
|
|
38
|
+
return isCursor(cursor) ? cursor : undefined;
|
|
39
39
|
}
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* The longest one walk may take across all of its pages. Each request is bound
|
|
42
|
+
* on its own by the transport, so this caps the sum: a slow API cannot hold a
|
|
43
|
+
* tool call open for ten pages at that limit each.
|
|
44
|
+
*/
|
|
45
|
+
const WALK_BUDGET_MS = 120_000;
|
|
46
|
+
function walkOf(api, operation, pageContract, baseRequest, maxPages) {
|
|
41
47
|
return {
|
|
42
|
-
itemsKey:
|
|
48
|
+
itemsKey: pageContract.itemsKey,
|
|
43
49
|
maxPages,
|
|
44
50
|
nextPage: (cursor) => api.request({
|
|
45
51
|
operation,
|
|
46
52
|
...baseRequest,
|
|
47
53
|
query: {
|
|
48
54
|
...baseRequest.query,
|
|
49
|
-
[
|
|
55
|
+
[pageContract.cursorQueryParam]: cursor,
|
|
50
56
|
},
|
|
51
57
|
}),
|
|
52
58
|
};
|
|
53
59
|
}
|
|
54
60
|
/**
|
|
55
61
|
* Follows `nextCursor` from the first page onward, merging each page's items
|
|
56
|
-
* until the cursor runs out
|
|
57
|
-
* Effect generator so the aggregation loop's
|
|
58
|
-
* outer program's first-page setup and result
|
|
62
|
+
* until the cursor runs out, the `maxPages` guard is hit, or the walk's time
|
|
63
|
+
* budget is spent. Kept as its own Effect generator so the aggregation loop's
|
|
64
|
+
* branching lives apart from the outer program's first-page setup and result
|
|
65
|
+
* assembly.
|
|
66
|
+
*
|
|
67
|
+
* The state lives in a `Ref` so the budget can cut the loop off mid-page and
|
|
68
|
+
* still hand back the pages collected so far.
|
|
59
69
|
*/
|
|
60
70
|
function collectRemainingPages(walk, first) {
|
|
61
71
|
const { itemsKey } = walk;
|
|
72
|
+
const firstCursor = cursorOf(first);
|
|
62
73
|
const initial = {
|
|
63
74
|
items: pageItems(first, itemsKey),
|
|
64
|
-
cursor:
|
|
75
|
+
cursor: firstCursor,
|
|
65
76
|
pagesFetched: 1,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// client a cursor to continue from.
|
|
69
|
-
stopped: false,
|
|
77
|
+
seen: HashSet.fromIterable(firstCursor === undefined ? [] : [firstCursor]),
|
|
78
|
+
halt: undefined,
|
|
70
79
|
};
|
|
71
|
-
return Effect.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
return Ref.make(initial).pipe(Effect.flatMap((ref) => walkPages(walk, ref).pipe(Effect.timeoutOrElse({
|
|
81
|
+
duration: Duration.millis(WALK_BUDGET_MS),
|
|
82
|
+
orElse: () => Ref.get(ref).pipe(Effect.map((state) => haltWalk(state, {
|
|
83
|
+
reason: "time_budget_exceeded",
|
|
84
|
+
what: `the ${WALK_BUDGET_MS / 1000}s time budget for walking pages ran out before page ${state.pagesFetched + 1} arrived`,
|
|
85
|
+
continuable: true,
|
|
86
|
+
}))),
|
|
87
|
+
}))));
|
|
88
|
+
}
|
|
89
|
+
/** The page-by-page loop, writing each step to `ref` so a cut-off keeps it. */
|
|
90
|
+
function walkPages(walk, ref) {
|
|
91
|
+
return Effect.gen(function* () {
|
|
92
|
+
// oxlint-disable-next-line effect/imperative-loops -- condition-driven loop over a mutable cursor; not a collection transform
|
|
93
|
+
while (true) {
|
|
94
|
+
const state = yield* Ref.get(ref);
|
|
95
|
+
if (state.halt !== undefined ||
|
|
96
|
+
state.cursor === undefined ||
|
|
97
|
+
state.pagesFetched >= walk.maxPages) {
|
|
98
|
+
return state;
|
|
99
|
+
}
|
|
100
|
+
// A failed later page must not throw away the pages already fetched.
|
|
101
|
+
// The result says it is partial and carries the cursor to retry from.
|
|
102
|
+
const page = yield* Effect.result(walk.nextPage(state.cursor));
|
|
103
|
+
// A refused token or a missing scope fails the call instead. A partial
|
|
104
|
+
// list would read as "page 3 is down", and only a failed tool result
|
|
105
|
+
// carries the challenge that sends the client back through sign-in.
|
|
106
|
+
if (Result.isFailure(page) && isAuthorizationFailure(page.failure)) {
|
|
107
|
+
return yield* Effect.fail(page.failure);
|
|
108
|
+
}
|
|
109
|
+
yield* Ref.set(ref, Result.match(page, {
|
|
110
|
+
onFailure: (error) => failedPage(state, formatError(error)),
|
|
111
|
+
onSuccess: (body) => foldPage(state, body, walk.itemsKey),
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function failedPage(state, reason) {
|
|
117
|
+
return haltWalk(state, {
|
|
118
|
+
reason,
|
|
119
|
+
what: `page ${state.pagesFetched + 1} failed (${reason})`,
|
|
120
|
+
continuable: true,
|
|
78
121
|
});
|
|
79
122
|
}
|
|
80
123
|
/**
|
|
81
124
|
* One more page folded into the walk's accumulated state, or a halt when the
|
|
82
|
-
* page came back in a shape the paginator cannot read
|
|
125
|
+
* page came back in a shape the paginator cannot read or broke the cursor
|
|
126
|
+
* chain.
|
|
83
127
|
*/
|
|
84
128
|
function foldPage(state, page, itemsKey) {
|
|
85
|
-
return
|
|
86
|
-
onNone: () => haltWalk(state
|
|
129
|
+
return readablePage(page, itemsKey).pipe(Option.match({
|
|
130
|
+
onNone: () => haltWalk(state, {
|
|
131
|
+
reason: "unreadable_page",
|
|
132
|
+
what: `page ${state.pagesFetched + 1} came back in a shape that could not be read (unreadable_page)`,
|
|
133
|
+
continuable: true,
|
|
134
|
+
}),
|
|
87
135
|
onSome: (readable) => mergePage(state, readable, itemsKey),
|
|
88
136
|
}));
|
|
89
137
|
}
|
|
90
138
|
/** The walk stopped where it stands, keeping the cursor that led to the halt. */
|
|
91
|
-
function haltWalk(state) {
|
|
92
|
-
return {
|
|
93
|
-
items: state.items,
|
|
94
|
-
cursor: state.cursor,
|
|
95
|
-
pagesFetched: state.pagesFetched,
|
|
96
|
-
stopped: true,
|
|
97
|
-
};
|
|
139
|
+
function haltWalk(state, halt) {
|
|
140
|
+
return { ...state, halt };
|
|
98
141
|
}
|
|
99
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* The walk carried one page further. A server that answers an empty page with a
|
|
144
|
+
* cursor, or hands back a cursor it already sent, would have the walk spin until
|
|
145
|
+
* `maxPages`, so either one halts it instead.
|
|
146
|
+
*/
|
|
100
147
|
function mergePage(state, page, itemsKey) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
148
|
+
const items = pageItems(page, itemsKey);
|
|
149
|
+
const cursor = cursorOf(page);
|
|
150
|
+
const advanced = {
|
|
151
|
+
items: [...state.items, ...items],
|
|
152
|
+
cursor,
|
|
104
153
|
pagesFetched: state.pagesFetched + 1,
|
|
105
|
-
|
|
154
|
+
seen: cursor === undefined ? state.seen : HashSet.add(state.seen, cursor),
|
|
155
|
+
halt: undefined,
|
|
106
156
|
};
|
|
157
|
+
if (cursor === undefined) {
|
|
158
|
+
return advanced;
|
|
159
|
+
}
|
|
160
|
+
if (HashSet.has(state.seen, cursor)) {
|
|
161
|
+
return haltWalk(advanced, {
|
|
162
|
+
reason: "repeated_cursor",
|
|
163
|
+
what: `page ${advanced.pagesFetched} sent back a cursor the walk had already followed (repeated_cursor)`,
|
|
164
|
+
continuable: false,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return Arr.isReadonlyArrayNonEmpty(items)
|
|
168
|
+
? advanced
|
|
169
|
+
: haltWalk(advanced, {
|
|
170
|
+
reason: "empty_page_with_cursor",
|
|
171
|
+
what: `page ${advanced.pagesFetched} was empty but offered another cursor (empty_page_with_cursor)`,
|
|
172
|
+
continuable: false,
|
|
173
|
+
});
|
|
107
174
|
}
|
|
108
175
|
/**
|
|
109
176
|
* Bounded server-side auto-pagination for list tools. Follows nextCursor up to
|
|
110
177
|
* maxPages, merging the items array, then returns a single aggregated result.
|
|
111
178
|
* If more pages remain past the guard, the result is explicitly marked
|
|
112
|
-
* `truncated`
|
|
179
|
+
* `truncated` and carries a `nextCursor` to continue from, so a partial result
|
|
180
|
+
* always says it is one.
|
|
113
181
|
*/
|
|
114
|
-
export function paginatedProgram(operation,
|
|
182
|
+
export function paginatedProgram(operation, pageContract, baseRequest, requestedMaxPages) {
|
|
115
183
|
return Effect.gen(function* () {
|
|
116
184
|
const api = yield* LucaApi;
|
|
117
|
-
const walk = walkOf(api, operation,
|
|
185
|
+
const walk = walkOf(api, operation, pageContract, baseRequest, requestedMaxPages ?? DEFAULT_MAX_PAGES);
|
|
118
186
|
const first = yield* api.request({ operation, ...baseRequest });
|
|
119
|
-
|
|
187
|
+
// A first page that carries no `nextCursor` key at all, or none of the
|
|
188
|
+
// array its page shape names, is returned exactly as the API sent it.
|
|
189
|
+
const walkable = readablePage(first, pageContract.itemsKey).pipe(Option.filter((page) => "nextCursor" in page));
|
|
120
190
|
return yield* Option.match(walkable, {
|
|
121
|
-
// A response that carries no cursor, or none of the array its page shape
|
|
122
|
-
// names, is returned exactly as the API sent it.
|
|
123
191
|
onNone: () => Effect.succeed(first),
|
|
124
|
-
onSome: (page) => collectRemainingPages(walk, page).pipe(Effect.map((collected) => aggregatePages(operation, page,
|
|
192
|
+
onSome: (page) => collectRemainingPages(walk, page).pipe(Effect.map((collected) => aggregatePages(operation, page, pageContract.itemsKey, collected))),
|
|
125
193
|
});
|
|
126
194
|
});
|
|
127
195
|
}
|
|
@@ -130,6 +198,11 @@ function aggregatePages(operation, first, itemsKey, collected) {
|
|
|
130
198
|
const truncated = collected.cursor !== undefined;
|
|
131
199
|
logPaginationTruncation(operation, collected.pagesFetched, truncated);
|
|
132
200
|
return {
|
|
201
|
+
// Placed first so it leads the JSON text a model reads. A list that stopped
|
|
202
|
+
// on a failed page must not read as the whole list.
|
|
203
|
+
...optionalField("warning", collected.halt === undefined
|
|
204
|
+
? undefined
|
|
205
|
+
: incompleteWarning(collected, collected.halt, itemsKey)),
|
|
133
206
|
// Preserve every non-item, non-cursor top-level field from the first page
|
|
134
207
|
// (e.g. analytics totals on comment-event lists). The first page's own
|
|
135
208
|
// `nextCursor` is dropped in favor of the aggregated `pagination` below,
|
|
@@ -140,16 +213,24 @@ function aggregatePages(operation, first, itemsKey, collected) {
|
|
|
140
213
|
pagesFetched: collected.pagesFetched,
|
|
141
214
|
truncated,
|
|
142
215
|
nextCursor: collected.cursor ?? null,
|
|
216
|
+
...optionalField("error", collected.halt?.reason),
|
|
143
217
|
},
|
|
144
218
|
};
|
|
145
219
|
}
|
|
220
|
+
/** What a caller reads first when the walk stopped early. */
|
|
221
|
+
function incompleteWarning(collected, halt, itemsKey) {
|
|
222
|
+
const continueFrom = collected.cursor === undefined || !halt.continuable
|
|
223
|
+
? ""
|
|
224
|
+
: ` Call again with cursor "${collected.cursor}" to fetch the rest.`;
|
|
225
|
+
return `INCOMPLETE LIST: ${halt.what}. \`${itemsKey}\` holds only the ${collected.items.length} items from the first ${collected.pagesFetched} page(s); do not treat it as the full list.${continueFrom}`;
|
|
226
|
+
}
|
|
146
227
|
/** Top-level first-page fields to carry through, minus items and nextCursor. */
|
|
147
228
|
function passthroughFields(first, itemsKey) {
|
|
148
229
|
// Dropping the `key !== itemsKey` guard is unobservable: the sole caller
|
|
149
230
|
// spreads this result and then re-assigns `[itemsKey]: items` immediately
|
|
150
231
|
// after (see the aggregation assembly), so any items value copied through here
|
|
151
232
|
// is overwritten before it can reach the output.
|
|
152
|
-
return
|
|
233
|
+
return R.filter(first,
|
|
153
234
|
// Stryker disable next-line ConditionalExpression: the items key is overwritten by the caller, so excluding it here changes nothing
|
|
154
235
|
(_value, key) => key !== itemsKey && key !== "nextCursor");
|
|
155
236
|
}
|