@setluca/mcp 0.2.1 → 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 +316 -0
- package/LICENSE +21 -0
- package/README.md +19 -288
- package/dist/annotations.js +33 -18
- package/dist/client-address.js +64 -0
- package/dist/completions.js +24 -27
- package/dist/config.js +89 -38
- package/dist/errors.js +94 -13
- 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 +103 -41
- package/dist/index.js +4 -1
- package/dist/oauth-challenge.js +74 -0
- package/dist/oauth-resolver.js +58 -61
- package/dist/observability.js +30 -15
- 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 +15 -3
- package/dist/operations/manifest.js +7 -6
- package/dist/operations/registry.js +102 -108
- package/dist/operations.js +4 -3
- package/dist/optional-field.js +15 -3
- package/dist/page-contract.js +74 -0
- package/dist/pagination.js +147 -67
- package/dist/prompts.js +122 -97
- package/dist/rate-limit.js +40 -0
- package/dist/remote.js +145 -65
- package/dist/resilience.js +107 -52
- package/dist/resolvers.js +125 -0
- package/dist/scopes.js +70 -17
- package/dist/serialization.js +32 -22
- package/dist/server.js +148 -132
- package/dist/standard-schema.js +215 -0
- package/dist/task-tools/analytics.js +145 -86
- package/dist/task-tools/bookings.js +168 -81
- package/dist/task-tools/cadences.js +30 -45
- package/dist/task-tools/calls.js +131 -124
- package/dist/task-tools/inbox.js +37 -47
- package/dist/task-tools/leads.js +49 -77
- package/dist/task-tools/runtime.js +75 -15
- package/dist/token-verification.js +42 -0
- package/dist/tool-cache.js +3 -2
- package/dist/tool.js +151 -96
- package/dist/version.js +1 -1
- package/package.json +37 -16
- package/dist/openapi-zod.js +0 -335
- package/dist/page-shape.js +0 -62
- 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,109 +1,176 @@
|
|
|
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
|
|
@@ -112,17 +179,17 @@ function mergePage(state, page, itemsKey) {
|
|
|
112
179
|
* `truncated` and carries a `nextCursor` to continue from, so a partial result
|
|
113
180
|
* always says it is one.
|
|
114
181
|
*/
|
|
115
|
-
export function paginatedProgram(operation,
|
|
182
|
+
export function paginatedProgram(operation, pageContract, baseRequest, requestedMaxPages) {
|
|
116
183
|
return Effect.gen(function* () {
|
|
117
184
|
const api = yield* LucaApi;
|
|
118
|
-
const walk = walkOf(api, operation,
|
|
185
|
+
const walk = walkOf(api, operation, pageContract, baseRequest, requestedMaxPages ?? DEFAULT_MAX_PAGES);
|
|
119
186
|
const first = yield* api.request({ operation, ...baseRequest });
|
|
120
|
-
|
|
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));
|
|
121
190
|
return yield* Option.match(walkable, {
|
|
122
|
-
// A response that carries no cursor, or none of the array its page shape
|
|
123
|
-
// names, is returned exactly as the API sent it.
|
|
124
191
|
onNone: () => Effect.succeed(first),
|
|
125
|
-
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))),
|
|
126
193
|
});
|
|
127
194
|
});
|
|
128
195
|
}
|
|
@@ -131,6 +198,11 @@ function aggregatePages(operation, first, itemsKey, collected) {
|
|
|
131
198
|
const truncated = collected.cursor !== undefined;
|
|
132
199
|
logPaginationTruncation(operation, collected.pagesFetched, truncated);
|
|
133
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)),
|
|
134
206
|
// Preserve every non-item, non-cursor top-level field from the first page
|
|
135
207
|
// (e.g. analytics totals on comment-event lists). The first page's own
|
|
136
208
|
// `nextCursor` is dropped in favor of the aggregated `pagination` below,
|
|
@@ -141,16 +213,24 @@ function aggregatePages(operation, first, itemsKey, collected) {
|
|
|
141
213
|
pagesFetched: collected.pagesFetched,
|
|
142
214
|
truncated,
|
|
143
215
|
nextCursor: collected.cursor ?? null,
|
|
216
|
+
...optionalField("error", collected.halt?.reason),
|
|
144
217
|
},
|
|
145
218
|
};
|
|
146
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
|
+
}
|
|
147
227
|
/** Top-level first-page fields to carry through, minus items and nextCursor. */
|
|
148
228
|
function passthroughFields(first, itemsKey) {
|
|
149
229
|
// Dropping the `key !== itemsKey` guard is unobservable: the sole caller
|
|
150
230
|
// spreads this result and then re-assigns `[itemsKey]: items` immediately
|
|
151
231
|
// after (see the aggregation assembly), so any items value copied through here
|
|
152
232
|
// is overwritten before it can reach the output.
|
|
153
|
-
return
|
|
233
|
+
return R.filter(first,
|
|
154
234
|
// Stryker disable next-line ConditionalExpression: the items key is overwritten by the caller, so excluding it here changes nothing
|
|
155
235
|
(_value, key) => key !== itemsKey && key !== "nextCursor");
|
|
156
236
|
}
|