@elmohq/sdk 0.0.0-claim → 0.1.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.
Files changed (129) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +325 -2
  3. package/dist/calls/index.d.mts +288 -0
  4. package/dist/calls/index.d.mts.map +1 -0
  5. package/dist/calls/index.mjs +390 -0
  6. package/dist/calls/index.mjs.map +1 -0
  7. package/dist/index.d.mts +2 -0
  8. package/dist/index.mjs +1029 -0
  9. package/dist/index.mjs.map +1 -0
  10. package/dist/request-options-DWYI_Ldj.d.mts +1550 -0
  11. package/dist/request-options-DWYI_Ldj.d.mts.map +1 -0
  12. package/dist/tools-7Hs5J2xb.d.mts +1945 -0
  13. package/dist/tools-7Hs5J2xb.d.mts.map +1 -0
  14. package/dist/tools-SwP0sK8H.mjs +2458 -0
  15. package/dist/tools-SwP0sK8H.mjs.map +1 -0
  16. package/dist/types/index.d.mts +2 -0
  17. package/dist/types/index.mjs +1 -0
  18. package/package.json +59 -2
  19. package/src/calls/brands-analytics-get.ts +33 -0
  20. package/src/calls/brands-citations-domains-list.ts +36 -0
  21. package/src/calls/brands-citations-urls-list.ts +32 -0
  22. package/src/calls/brands-create.ts +19 -0
  23. package/src/calls/brands-get.ts +19 -0
  24. package/src/calls/brands-list.ts +18 -0
  25. package/src/calls/brands-opportunities-get.ts +42 -0
  26. package/src/calls/brands-prompt-performance-list.ts +39 -0
  27. package/src/calls/brands-query-fanout-get.ts +30 -0
  28. package/src/calls/brands-tags-list.ts +28 -0
  29. package/src/calls/brands-update.ts +20 -0
  30. package/src/calls/competitors-create.ts +23 -0
  31. package/src/calls/competitors-delete.ts +19 -0
  32. package/src/calls/competitors-get.ts +19 -0
  33. package/src/calls/competitors-list.ts +23 -0
  34. package/src/calls/competitors-update.ts +24 -0
  35. package/src/calls/index.ts +33 -0
  36. package/src/calls/me-get.ts +18 -0
  37. package/src/calls/models-list.ts +20 -0
  38. package/src/calls/organizations-billing-get.ts +37 -0
  39. package/src/calls/organizations-get.ts +24 -0
  40. package/src/calls/organizations-list.ts +27 -0
  41. package/src/calls/prompts-create.ts +27 -0
  42. package/src/calls/prompts-delete.ts +28 -0
  43. package/src/calls/prompts-get.ts +23 -0
  44. package/src/calls/prompts-list.ts +28 -0
  45. package/src/calls/prompts-runs-get.ts +27 -0
  46. package/src/calls/prompts-runs-list.ts +30 -0
  47. package/src/calls/prompts-snapshot-get.ts +28 -0
  48. package/src/calls/prompts-update.ts +29 -0
  49. package/src/calls/reports-create.ts +31 -0
  50. package/src/calls/reports-get.ts +27 -0
  51. package/src/calls/reports-list.ts +28 -0
  52. package/src/calls/tools-analyze.ts +26 -0
  53. package/src/client.ts +662 -0
  54. package/src/index.ts +21 -0
  55. package/src/internal/binding/rest/binding.ts +127 -0
  56. package/src/internal/binding/rest/errors.ts +107 -0
  57. package/src/internal/binding/rest/path.ts +9 -0
  58. package/src/internal/binding/rest/url.ts +97 -0
  59. package/src/internal/codec/json.ts +66 -0
  60. package/src/internal/codec/registry.ts +55 -0
  61. package/src/internal/core/backoff.ts +170 -0
  62. package/src/internal/core/body.ts +54 -0
  63. package/src/internal/core/browser.ts +11 -0
  64. package/src/internal/core/call-promise.ts +145 -0
  65. package/src/internal/core/callable.ts +11 -0
  66. package/src/internal/core/client.ts +89 -0
  67. package/src/internal/core/config.ts +84 -0
  68. package/src/internal/core/credentials.ts +40 -0
  69. package/src/internal/core/dispatch.ts +227 -0
  70. package/src/internal/core/env.ts +68 -0
  71. package/src/internal/core/errors.ts +431 -0
  72. package/src/internal/core/features.ts +60 -0
  73. package/src/internal/core/lazy-promise.ts +39 -0
  74. package/src/internal/core/metadata.ts +79 -0
  75. package/src/internal/core/params.ts +98 -0
  76. package/src/internal/core/protocol.ts +17 -0
  77. package/src/internal/core/read-reply.ts +7 -0
  78. package/src/internal/core/text.ts +7 -0
  79. package/src/internal/core/types.ts +556 -0
  80. package/src/internal/feature/auth.ts +201 -0
  81. package/src/internal/feature/interceptors.ts +36 -0
  82. package/src/internal/feature/logger.ts +222 -0
  83. package/src/internal/feature/retry.ts +165 -0
  84. package/src/internal/feature/rules.ts +54 -0
  85. package/src/internal/feature/timeout.ts +146 -0
  86. package/src/internal/feature/validate.ts +74 -0
  87. package/src/internal/page/page.ts +402 -0
  88. package/src/internal/transport/fetch.ts +82 -0
  89. package/src/resources/analytics.ts +363 -0
  90. package/src/resources/brands.ts +285 -0
  91. package/src/resources/competitors.ts +212 -0
  92. package/src/resources/identity.ts +48 -0
  93. package/src/resources/models.ts +32 -0
  94. package/src/resources/opportunities.ts +79 -0
  95. package/src/resources/organizations.ts +181 -0
  96. package/src/resources/prompts.ts +292 -0
  97. package/src/resources/reports.ts +211 -0
  98. package/src/resources/runs.ts +123 -0
  99. package/src/resources/shared/auth.ts +13 -0
  100. package/src/resources/shared/request-options.ts +26 -0
  101. package/src/resources/shared/resource.ts +23 -0
  102. package/src/resources/shared/rule.ts +25 -0
  103. package/src/resources/snapshots.ts +54 -0
  104. package/src/resources/tags.ts +46 -0
  105. package/src/resources/tools.ts +68 -0
  106. package/src/types/analytics.ts +497 -0
  107. package/src/types/brands.ts +228 -0
  108. package/src/types/competitors.ts +196 -0
  109. package/src/types/identity.ts +55 -0
  110. package/src/types/index.ts +163 -0
  111. package/src/types/models.ts +40 -0
  112. package/src/types/opportunities.ts +84 -0
  113. package/src/types/organizations.ts +157 -0
  114. package/src/types/prompts.ts +231 -0
  115. package/src/types/reports.ts +266 -0
  116. package/src/types/runs.ts +131 -0
  117. package/src/types/shared/brand-id-path.ts +2 -0
  118. package/src/types/shared/conflict-error.ts +7 -0
  119. package/src/types/shared/error.ts +29 -0
  120. package/src/types/shared/limit.ts +8 -0
  121. package/src/types/shared/mention-entry.ts +21 -0
  122. package/src/types/shared/model-filter.ts +15 -0
  123. package/src/types/shared/not-found-error.ts +4 -0
  124. package/src/types/shared/page.ts +7 -0
  125. package/src/types/shared/pagination.ts +7 -0
  126. package/src/types/shared/validation-error.ts +4 -0
  127. package/src/types/snapshots.ts +88 -0
  128. package/src/types/tags.ts +47 -0
  129. package/src/types/tools.ts +78 -0
@@ -0,0 +1,146 @@
1
+ import { TIMER_LIMIT } from '../core/backoff';
2
+ import { replyWith } from '../core/body';
3
+ import { calledOffWith, safeAddress, TimeoutError } from '../core/errors';
4
+ import type {
5
+ CallableDescriptor,
6
+ CallTimeout,
7
+ Feature,
8
+ PreparedRequest,
9
+ Result,
10
+ Send,
11
+ } from '../core/types';
12
+
13
+ /**
14
+ * Chooses the limit of one call, given the operation as `METHOD /path` and the
15
+ * limit it would otherwise get. `undefined` keeps that limit.
16
+ */
17
+ export type TimeoutPolicy = (operation: string, timeout: CallTimeout) => CallTimeout | undefined;
18
+
19
+ /** A limit in milliseconds, `false` for none, or a policy choosing one per call. */
20
+ export type TimeoutValue = CallTimeout | TimeoutPolicy;
21
+
22
+ export interface TimeoutOptions {
23
+ ms?: number;
24
+ }
25
+
26
+ function describeTarget(request: PreparedRequest): string | undefined {
27
+ const { method } = request.callable;
28
+ if (typeof request.address !== 'string') return undefined;
29
+ const address = safeAddress(request.address);
30
+ return method ? `${method.toUpperCase()} "${address}"` : `"${address}"`;
31
+ }
32
+
33
+ export const DEFAULT_TIMEOUT = 60_000;
34
+
35
+ function operationKey({ address, method }: CallableDescriptor): string {
36
+ return method ? `${method.toUpperCase()} ${address}` : address;
37
+ }
38
+
39
+ export function resolveTimeout(base: TimeoutOptions, request: PreparedRequest): number | undefined {
40
+ const own = request.callable.timeout ?? base.ms ?? DEFAULT_TIMEOUT;
41
+ const option = request.options['timeout'] as TimeoutValue | undefined;
42
+ const ms =
43
+ typeof option === 'function'
44
+ ? (option(operationKey(request.callable), own) ?? own)
45
+ : (option ?? own);
46
+ if (ms === false) return undefined;
47
+ return ms > 0 && Number.isFinite(ms) ? ms : undefined;
48
+ }
49
+
50
+ function watchedReply(
51
+ response: Response,
52
+ wait: <T>(pending: Promise<T>) => Promise<T>,
53
+ done: () => void,
54
+ ): Response {
55
+ const reader = response.body!.getReader();
56
+ const body = new ReadableStream<Uint8Array>({
57
+ cancel(reason) {
58
+ done();
59
+ return reader.cancel(reason);
60
+ },
61
+ async pull(controller) {
62
+ const step = await wait(reader.read()).catch((error: unknown) => {
63
+ done();
64
+ reader.cancel(error).catch(done);
65
+ throw error;
66
+ });
67
+ if (step.done) {
68
+ done();
69
+ controller.close();
70
+ } else {
71
+ controller.enqueue(step.value);
72
+ }
73
+ },
74
+ });
75
+ return replyWith(body, response);
76
+ }
77
+
78
+ export function timeoutFeature(options: TimeoutOptions = {}): Feature {
79
+ async function bounded(request: PreparedRequest, next: Send, stream: boolean): Promise<Result> {
80
+ const ms = resolveTimeout(options, request);
81
+ if (ms === undefined) return next(request);
82
+ const delay = Math.min(ms, TIMER_LIMIT);
83
+ let left = delay;
84
+
85
+ const caller = request.signal;
86
+ const controller = new AbortController();
87
+ const { signal } = controller;
88
+
89
+ function abortWithCaller(): void {
90
+ controller.abort(caller?.reason);
91
+ }
92
+
93
+ if (caller?.aborted) abortWithCaller();
94
+ caller?.addEventListener('abort', abortWithCaller, { once: true });
95
+
96
+ const abandoned = new Promise<never>((_, reject) => {
97
+ function giveUp(): void {
98
+ reject(calledOffWith(signal.reason));
99
+ }
100
+ if (signal.aborted) giveUp();
101
+ else signal.addEventListener('abort', giveUp, { once: true });
102
+ });
103
+ abandoned.catch(() => {});
104
+
105
+ function expire(): void {
106
+ controller.abort(new TimeoutError(ms, undefined, describeTarget(request)));
107
+ }
108
+
109
+ async function wait<T>(pending: Promise<T>): Promise<T> {
110
+ const started = Date.now();
111
+ const timer = setTimeout(expire, left);
112
+ try {
113
+ return await Promise.race([pending, abandoned]);
114
+ } finally {
115
+ clearTimeout(timer);
116
+ left = stream ? delay : left - (Date.now() - started);
117
+ }
118
+ }
119
+
120
+ function done(): void {
121
+ caller?.removeEventListener('abort', abortWithCaller);
122
+ }
123
+
124
+ request.signal = signal;
125
+ const result = await wait(next(request))
126
+ .catch((error: unknown) => {
127
+ done();
128
+ throw error;
129
+ })
130
+ .finally(() => {
131
+ request.signal = caller;
132
+ });
133
+ const { response } = result;
134
+ if (response?.body && (stream || request.options.unread)) {
135
+ return { ...result, response: watchedReply(response, wait, done) };
136
+ }
137
+ done();
138
+ return result;
139
+ }
140
+
141
+ return {
142
+ name: 'timeout',
143
+ onOpen: (request, next) => bounded(request, next, true),
144
+ onSend: (request, next) => bounded(request, next, false),
145
+ };
146
+ }
@@ -0,0 +1,74 @@
1
+ import { unreadableValue } from '../core/errors';
2
+ import { readReply } from '../core/read-reply';
3
+ import type { CallableDescriptor, Feature, ResolvedOptions } from '../core/types';
4
+
5
+ export type Validate<TIn = unknown, TOut = TIn> = (value: TIn) => Promise<TOut> | TOut;
6
+
7
+ export async function runValidator<T>(
8
+ validate: Validate<T, T> | undefined,
9
+ value: T,
10
+ options: ResolvedOptions,
11
+ response?: Response,
12
+ ): Promise<T> {
13
+ if (!validate || options['validate'] === false) return value;
14
+ return await readReply(validate, value, response);
15
+ }
16
+
17
+ export interface CallableValidators {
18
+ error?: Validate;
19
+ request?: Validate<ResolvedOptions, Partial<ResolvedOptions>>;
20
+ response?: Validate;
21
+ }
22
+
23
+ export function validatorsOf(callable: CallableDescriptor): CallableValidators | undefined {
24
+ return callable.validators as CallableValidators | undefined;
25
+ }
26
+
27
+ export function validateFeature(): Feature {
28
+ return {
29
+ name: 'validate',
30
+ async onOptions(options, callable) {
31
+ const validate = validatorsOf(callable)?.request;
32
+ if (!validate || options['validate'] === false) return;
33
+ Object.assign(options, await validate(options));
34
+ },
35
+ async onResult(result, request) {
36
+ const validators = validatorsOf(request.callable);
37
+ if (!validators) return result;
38
+ if (result.error !== undefined && result.error !== null) {
39
+ result.error = await runValidator(
40
+ validators.error,
41
+ result.error,
42
+ request.options,
43
+ result.response,
44
+ );
45
+ } else if (result.data !== undefined) {
46
+ result.data = await runValidator(
47
+ validators.response,
48
+ result.data,
49
+ request.options,
50
+ result.response,
51
+ );
52
+ }
53
+ return result;
54
+ },
55
+ };
56
+ }
57
+
58
+ export function decoded<TIn, TOut>(at: string, value: TIn, read: (value: TIn) => TOut): TOut {
59
+ let result: TOut;
60
+ try {
61
+ result = read(value);
62
+ } catch (cause) {
63
+ throw unreadableValue(at, value, cause);
64
+ }
65
+ if (result instanceof Date && Number.isNaN(result.getTime())) throw unreadableValue(at, value);
66
+ return result;
67
+ }
68
+
69
+ export function reviving<T>(revive: (value: T) => void): Validate {
70
+ return (data) => {
71
+ if (data != null) revive(data as T);
72
+ return data;
73
+ };
74
+ }
@@ -0,0 +1,402 @@
1
+ import { awaitedResult } from '../core/call-promise';
2
+ import type { Client } from '../core/client';
3
+ import { APIError, ElmoError } from '../core/errors';
4
+ import { LazyPromise, unattended } from '../core/lazy-promise';
5
+ import type {
6
+ CallableDescriptor,
7
+ ErrorMode,
8
+ PageEnvelope,
9
+ PageResult,
10
+ PaginationDescriptor,
11
+ PaginationStyle,
12
+ ResolvedOptions,
13
+ Result,
14
+ } from '../core/types';
15
+
16
+ /**
17
+ * One page of a paginated call. Iterating a page walks from it to the end of
18
+ * the collection.
19
+ */
20
+ export interface Page<TItem = unknown, TBody = unknown> extends AsyncIterable<TItem> {
21
+ /** The whole reply body, counts and all. */
22
+ readonly body: TBody;
23
+ /** The items on this page. */
24
+ readonly data: ReadonlyArray<TItem>;
25
+ /** The next page. Throws if `hasNextPage()` is `false`. */
26
+ getNextPage(): Promise<Page<TItem, TBody>>;
27
+ /** Whether a page follows this one. It sends no request. */
28
+ hasNextPage(): boolean;
29
+ /**
30
+ * Where the page after this one begins, or `undefined` when none follows.
31
+ * Pass it back as `pageParam` to resume from here.
32
+ */
33
+ readonly nextPageParam: unknown;
34
+ /** The HTTP status of the reply that carried this page. */
35
+ readonly status: number;
36
+ }
37
+
38
+ interface Walk {
39
+ readonly cursor: unknown;
40
+ readonly descriptor: CallableDescriptor;
41
+ readonly options: ResolvedOptions;
42
+ readonly position: number;
43
+ }
44
+
45
+ function readPath(value: unknown, path: string | undefined): unknown {
46
+ if (!path) return value;
47
+ let current = value;
48
+ for (const key of path.split('.')) {
49
+ if (current === null || typeof current !== 'object') return undefined;
50
+ current = (current as Record<string, unknown>)[key];
51
+ }
52
+ return current;
53
+ }
54
+
55
+ function readItems(data: unknown, pagination: PaginationDescriptor): ReadonlyArray<unknown> {
56
+ const items = readPath(data, pagination.items);
57
+ return Array.isArray(items) ? items : [];
58
+ }
59
+
60
+ function withPosition(
61
+ options: ResolvedOptions,
62
+ pagination: PaginationDescriptor,
63
+ value: unknown,
64
+ ): ResolvedOptions {
65
+ const name = pagination.param;
66
+ if (!name) return options;
67
+ if ((pagination.in ?? 'query') === 'body') {
68
+ return { ...options, body: { ...(options.body as object | undefined), [name]: value } };
69
+ }
70
+ return { ...options, query: { ...options.query, [name]: value } };
71
+ }
72
+
73
+ function resumeAt(walk: Walk, pagination: PaginationDescriptor, param: unknown): Walk {
74
+ if (pagination.style === 'link') {
75
+ const options = { ...walk.options };
76
+ delete options.query;
77
+ return { ...walk, descriptor: { ...walk.descriptor, address: param as string }, options };
78
+ }
79
+ const options = withPosition(walk.options, pagination, param);
80
+ if (pagination.style === 'cursor') return { ...walk, cursor: param, options };
81
+ return { ...walk, options, position: param as number };
82
+ }
83
+
84
+ function readCount(data: unknown, path: string | undefined): number | undefined {
85
+ if (!path) return undefined;
86
+ const value = readPath(data, path);
87
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined;
88
+ return value;
89
+ }
90
+
91
+ function startOf(pagination: PaginationDescriptor): number {
92
+ return pagination.start ?? (pagination.style === 'page' ? 1 : 0);
93
+ }
94
+
95
+ function stepCounted(
96
+ walk: Walk,
97
+ pagination: PaginationDescriptor,
98
+ result: Result,
99
+ count: number,
100
+ limit: number | undefined,
101
+ ): unknown {
102
+ if (!count) return undefined;
103
+
104
+ const size = readCount(result.data, pagination.size) ?? limit;
105
+ const position = walk.position + (pagination.style === 'page' ? 1 : size || count);
106
+ const start = startOf(pagination);
107
+
108
+ const pages = pagination.style === 'page' ? readCount(result.data, pagination.pages) : undefined;
109
+ const total = readCount(result.data, pagination.total);
110
+ if (pages === undefined && total === undefined && size !== undefined && count < size) {
111
+ return undefined;
112
+ }
113
+ if (pages !== undefined && position - start >= pages) return undefined;
114
+ if (total !== undefined) {
115
+ const read = pagination.style === 'page' ? (position - start) * (size ?? count) : position;
116
+ if (read >= total) return undefined;
117
+ }
118
+
119
+ return position;
120
+ }
121
+
122
+ type Stepper = (
123
+ walk: Walk,
124
+ pagination: PaginationDescriptor,
125
+ result: Result,
126
+ count: number,
127
+ limit: number | undefined,
128
+ ) => unknown;
129
+
130
+ const STEPPERS: Partial<Readonly<Record<PaginationStyle, Stepper>>> = { page: stepCounted };
131
+
132
+ const SCHEME_RE = /^[a-z][a-z\d+.-]*:/i;
133
+
134
+ function pageURL(address: unknown): URL | undefined {
135
+ if (typeof address !== 'string') return undefined;
136
+ try {
137
+ const url = new URL(address, (globalThis as { location?: { href?: string } }).location?.href);
138
+ return url.origin === 'null' ? undefined : url;
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ function followLink(client: Client, walk: Walk, link: string): string {
145
+ const own = pageURL(client.resolveAddress(walk.descriptor, walk.options));
146
+ if (!own) {
147
+ if (!SCHEME_RE.test(link) && !link.startsWith('//')) return link;
148
+ throw new ElmoError(
149
+ 'Pagination stopped: the next page names a host, and no base URL says which host this client sends its credentials to.',
150
+ );
151
+ }
152
+ const target = new URL(link, own);
153
+ if (target.origin !== own.origin) {
154
+ throw new ElmoError(
155
+ `Pagination stopped: the next page is on ${target.origin}, and this client sends its credentials only to ${own.origin}.`,
156
+ );
157
+ }
158
+ return SCHEME_RE.test(link) ? link : target.href;
159
+ }
160
+
161
+ function saysMore(data: unknown, pagination: PaginationDescriptor): boolean {
162
+ return !pagination.more || readPath(data, pagination.more) !== false;
163
+ }
164
+
165
+ function stepWalk(
166
+ client: Client,
167
+ walk: Walk,
168
+ pagination: PaginationDescriptor,
169
+ result: Result,
170
+ count: number,
171
+ limit: number | undefined,
172
+ ): unknown {
173
+ const step = STEPPERS[pagination.style];
174
+ if (!step) {
175
+ throw new ElmoError(
176
+ `Cannot walk "${walk.descriptor.address}": this client does not page by "${pagination.style}".`,
177
+ );
178
+ }
179
+ if (!saysMore(result.data, pagination)) return undefined;
180
+ const next = step(walk, pagination, result, count, limit);
181
+ return pagination.style === 'link' && next !== undefined
182
+ ? followLink(client, walk, next as string)
183
+ : next;
184
+ }
185
+
186
+ async function* walkFrom<TItem>(page: Page<TItem>, maxPages: number): AsyncGenerator<TItem> {
187
+ let current = page;
188
+ for (let index = 1; ; index++) {
189
+ for (const item of current.data) yield item;
190
+ if (index >= maxPages || !current.hasNextPage()) return;
191
+ current = await current.getNextPage();
192
+ }
193
+ }
194
+
195
+ async function loadPage<TItem, TBody>(
196
+ client: Client,
197
+ walk: Walk,
198
+ pagination: PaginationDescriptor,
199
+ limit: number | undefined,
200
+ maxPages: number,
201
+ ): Promise<Page<TItem, TBody>> {
202
+ const result = (await client.call(walk.descriptor, walk.options)) as Result;
203
+ if (result.error !== undefined) throw result.error;
204
+
205
+ const data = readItems(result.data, pagination) as ReadonlyArray<TItem>;
206
+ const param = stepWalk(client, walk, pagination, result, data.length, limit);
207
+
208
+ const page: Page<TItem, TBody> = {
209
+ [Symbol.asyncIterator]: () => walkFrom(page, maxPages),
210
+ body: result.data as TBody,
211
+ data,
212
+ getNextPage: () => {
213
+ if (param === undefined) {
214
+ return Promise.reject(
215
+ new ElmoError(
216
+ `"${walk.descriptor.address}" has no page after this one. Check \`hasNextPage()\` first.`,
217
+ ),
218
+ );
219
+ }
220
+ return loadPage<TItem, TBody>(
221
+ client,
222
+ resumeAt(walk, pagination, param),
223
+ pagination,
224
+ limit,
225
+ maxPages,
226
+ );
227
+ },
228
+ hasNextPage: () => param !== undefined,
229
+ nextPageParam: param,
230
+ status: result.status as number,
231
+ };
232
+
233
+ return page;
234
+ }
235
+
236
+ export const DEFAULT_MAX_PAGES = 1000;
237
+
238
+ function maxPagesFor(client: Client, options: ResolvedOptions): number {
239
+ return (options['maxPages'] ?? client.setup.defaults['maxPages'] ?? DEFAULT_MAX_PAGES) as number;
240
+ }
241
+
242
+ function openedAt(options: ResolvedOptions, pagination: PaginationDescriptor): unknown {
243
+ if (!pagination.param) return undefined;
244
+ const layer = options[pagination.in ?? 'query'] as Record<string, unknown> | undefined;
245
+ return layer?.[pagination.param];
246
+ }
247
+
248
+ function paginationOf(callable: CallableDescriptor): PaginationDescriptor {
249
+ if (callable.pagination) return callable.pagination;
250
+ throw new ElmoError(`"${callable.address}" is not a paged call, so it has no pages to walk.`);
251
+ }
252
+
253
+ function openWalk(callable: CallableDescriptor, options: ResolvedOptions): Walk {
254
+ const pagination = paginationOf(callable);
255
+ const opened = openedAt(options, pagination);
256
+ return {
257
+ cursor: pagination.style === 'cursor' ? opened : undefined,
258
+ descriptor: callable,
259
+ options: { ...options, raw: true },
260
+ position: typeof opened === 'number' && Number.isFinite(opened) ? opened : startOf(pagination),
261
+ };
262
+ }
263
+
264
+ function pageStream<TItem, TBody>(
265
+ load: () => Promise<Page<TItem, TBody>>,
266
+ maxPages: number,
267
+ ): AsyncIterable<Page<TItem, TBody>> {
268
+ return {
269
+ async *[Symbol.asyncIterator](): AsyncGenerator<Page<TItem, TBody>> {
270
+ let current = await load();
271
+ for (let index = 1; ; index++) {
272
+ yield current;
273
+ if (index >= maxPages || !current.hasNextPage()) return;
274
+ current = await current.getNextPage();
275
+ }
276
+ },
277
+ };
278
+ }
279
+
280
+ function requestedLimit(
281
+ options: ResolvedOptions,
282
+ pagination: PaginationDescriptor,
283
+ ): number | undefined {
284
+ if (!pagination.limitParam) return undefined;
285
+ const layer = options[pagination.in ?? 'query'] as Record<string, unknown> | undefined;
286
+ const limit = Number(layer?.[pagination.limitParam]);
287
+ return Number.isFinite(limit) && limit > 0 ? limit : undefined;
288
+ }
289
+
290
+ /**
291
+ * A paginated call. Awaiting it gives the first page, or its result envelope
292
+ * under the `'return'` error mode. Iterating it gives every item on every page,
293
+ * and throws on a failed page under either mode.
294
+ */
295
+ export class PagePromise<
296
+ TItem = unknown,
297
+ TBody = unknown,
298
+ TErrors = unknown,
299
+ TAwaited = Page<TItem, TBody>,
300
+ TMode extends ErrorMode = 'throw',
301
+ >
302
+ extends LazyPromise<PageResult<TAwaited, TErrors, TMode>>
303
+ implements AsyncIterable<TItem>
304
+ {
305
+ private envelope: Promise<PageEnvelope<TAwaited, TErrors>> | undefined;
306
+
307
+ private readonly callable: CallableDescriptor;
308
+ private readonly client: Client;
309
+ private readonly limit: number | undefined;
310
+ private readonly maxPages: number;
311
+ private readonly options: ResolvedOptions;
312
+ private readonly pagination: PaginationDescriptor;
313
+ private readonly project: (page: Page<TItem, TBody>) => TAwaited;
314
+
315
+ private readonly first: Promise<Page<TItem, TBody>>;
316
+
317
+ constructor(
318
+ client: Client,
319
+ callable: CallableDescriptor,
320
+ options: ResolvedOptions,
321
+ project: (page: Page<TItem, TBody>) => TAwaited,
322
+ ) {
323
+ super();
324
+ this.callable = callable;
325
+ this.client = client;
326
+ this.options = options;
327
+ this.pagination = paginationOf(callable);
328
+ this.limit = requestedLimit(options, this.pagination);
329
+ this.maxPages = maxPagesFor(client, options);
330
+ this.project = project;
331
+ const opened = openWalk(this.callable, this.options);
332
+ const param = options['pageParam'];
333
+ this.first = unattended(
334
+ loadPage<TItem, TBody>(
335
+ this.client,
336
+ param === undefined ? opened : resumeAt(opened, this.pagination, param),
337
+ this.pagination,
338
+ this.limit,
339
+ this.maxPages,
340
+ ),
341
+ );
342
+ }
343
+
344
+ /** Every item, page after page, up to `maxPages`. */
345
+ async *[Symbol.asyncIterator](): AsyncGenerator<TItem> {
346
+ yield* walkFrom(await this.first, this.maxPages);
347
+ }
348
+
349
+ /** Every page, up to `maxPages`. */
350
+ iterPages(): AsyncIterable<Page<TItem, TBody>> {
351
+ return pageStream(() => this.first, this.maxPages);
352
+ }
353
+
354
+ /**
355
+ * The whole result envelope. A failure of the call is returned on it, not
356
+ * thrown. What one of your own functions throws, such as a hook, is thrown
357
+ * as it is.
358
+ */
359
+ result(): Promise<PageEnvelope<TAwaited, TErrors>> {
360
+ return (this.envelope ??= this.read());
361
+ }
362
+
363
+ /** The page alone, with a failure thrown rather than handed back. */
364
+ async unwrap(): Promise<TAwaited> {
365
+ const result = await this.result();
366
+ if (result.ok === false) throw result.error;
367
+ return result.data;
368
+ }
369
+
370
+ private async read(): Promise<PageEnvelope<TAwaited, TErrors>> {
371
+ try {
372
+ const page = await this.first;
373
+ return { data: this.project(page), ok: true, status: page.status };
374
+ } catch (error) {
375
+ if (!(error instanceof ElmoError)) throw error;
376
+ const status = error instanceof APIError ? error.status : undefined;
377
+ return { error, ok: false, status } as PageEnvelope<TAwaited, TErrors>;
378
+ }
379
+ }
380
+
381
+ protected override settle(): Promise<PageResult<TAwaited, TErrors, TMode>> {
382
+ return this.result().then((result) => awaitedResult(this.client, this.options, result));
383
+ }
384
+ }
385
+
386
+ export function pages<
387
+ TItem = unknown,
388
+ TBody = unknown,
389
+ TErrors = unknown,
390
+ TMode extends ErrorMode = 'throw',
391
+ >(
392
+ client: Client,
393
+ callable: CallableDescriptor,
394
+ options: ResolvedOptions = {},
395
+ ): PagePromise<TItem, TBody, TErrors, Page<TItem, TBody>, TMode> {
396
+ return new PagePromise<TItem, TBody, TErrors, Page<TItem, TBody>, TMode>(
397
+ client,
398
+ callable,
399
+ options,
400
+ (page) => page,
401
+ );
402
+ }
@@ -0,0 +1,82 @@
1
+ import { isStreamed } from '../core/body';
2
+ import { safeAddress, unsendableAddress } from '../core/errors';
3
+ import { metadataHeaders } from '../core/metadata';
4
+ import type { PreparedRequest, Transport } from '../core/types';
5
+
6
+ /** A `fetch` function, called with the full address and a built `RequestInit`. */
7
+ export type Fetch = (input: string, init: RequestInit) => Promise<Response>;
8
+
9
+ /** What `fetch` takes, less what the client sets itself. */
10
+ export interface FetchOptions extends RequestInit {
11
+ /** Set by the client. */
12
+ body?: never;
13
+ /** Set by the client. Pass `headers` to the call. */
14
+ headers?: never;
15
+ /** Set by the client. */
16
+ method?: never;
17
+ /** Set by the client. Pass `signal` to the call. */
18
+ signal?: never;
19
+ }
20
+
21
+ function checkAddress(address: string): void {
22
+ let url: URL;
23
+ try {
24
+ url = new URL(address, (globalThis as { location?: { href: string } }).location?.href);
25
+ } catch {
26
+ throw unsendableAddress(
27
+ safeAddress(address),
28
+ 'is not a full address',
29
+ 'Start the base URL with `https://`.',
30
+ );
31
+ }
32
+ const shown = `${url.protocol}//${url.host}${url.pathname}`;
33
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
34
+ throw unsendableAddress(
35
+ shown,
36
+ `uses \`${url.protocol}\`, which fetch does not send`,
37
+ 'Start the base URL with `https://`.',
38
+ );
39
+ }
40
+ if (url.username || url.password) {
41
+ throw unsendableAddress(
42
+ shown,
43
+ 'holds a user name or password, which fetch refuses',
44
+ 'Pass the credential as an option instead.',
45
+ );
46
+ }
47
+ }
48
+
49
+ const OWNED = ['body', 'headers', 'method', 'signal'] as const;
50
+
51
+ function extraInit(request: PreparedRequest<string>): RequestInit | undefined {
52
+ const extra = request.options['fetchOptions'] as RequestInit | undefined;
53
+ if (!extra) return undefined;
54
+ const rest: RequestInit = { ...extra };
55
+ for (const key of OWNED) delete rest[key];
56
+ return rest;
57
+ }
58
+
59
+ function toRequestInit(request: PreparedRequest<string>): RequestInit {
60
+ return {
61
+ ...extraInit(request),
62
+ ...(request.body !== undefined && { body: request.body }),
63
+ ...(isStreamed(request.body) && { duplex: 'half' }),
64
+ headers: metadataHeaders(request.meta),
65
+ method: (request.callable.method ?? 'get').toUpperCase(),
66
+ ...(request.signal !== undefined && { signal: request.signal }),
67
+ };
68
+ }
69
+
70
+ export function createFetchTransport(fetchFn?: Fetch): Transport<string, 'unary'> {
71
+ function send(request: PreparedRequest<string>): Promise<Response> {
72
+ const configured = request.options['fetch'] as Fetch | undefined;
73
+ const call = configured ?? fetchFn ?? globalThis.fetch;
74
+ if (call === globalThis.fetch) checkAddress(request.address);
75
+ return call(request.address, toRequestInit(request));
76
+ }
77
+
78
+ return {
79
+ name: 'fetch',
80
+ unary: send,
81
+ };
82
+ }