@distilled.cloud/core 0.30.3 → 1.0.0-rc.10
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/LICENSE +201 -0
- package/lib/api.d.ts +165 -0
- package/lib/api.d.ts.map +1 -0
- package/lib/api.js +190 -0
- package/lib/api.js.map +1 -0
- package/lib/category.d.ts +4 -4
- package/lib/category.js +4 -4
- package/lib/codegen/boolean-string-enums.d.ts +36 -0
- package/lib/codegen/boolean-string-enums.d.ts.map +1 -0
- package/lib/codegen/boolean-string-enums.js +94 -0
- package/lib/codegen/boolean-string-enums.js.map +1 -0
- package/lib/codegen/boolean-string-enums.test.d.ts +2 -0
- package/lib/codegen/boolean-string-enums.test.d.ts.map +1 -0
- package/lib/codegen/boolean-string-enums.test.js +147 -0
- package/lib/codegen/boolean-string-enums.test.js.map +1 -0
- package/lib/codegen/cli.d.ts +79 -0
- package/lib/codegen/cli.d.ts.map +1 -0
- package/lib/codegen/cli.js +149 -0
- package/lib/codegen/cli.js.map +1 -0
- package/lib/codegen/emit.d.ts +125 -0
- package/lib/codegen/emit.d.ts.map +1 -0
- package/lib/codegen/emit.js +101 -0
- package/lib/codegen/emit.js.map +1 -0
- package/lib/codegen/format.d.ts +23 -0
- package/lib/codegen/format.d.ts.map +1 -0
- package/lib/codegen/format.js +28 -0
- package/lib/codegen/format.js.map +1 -0
- package/lib/codegen/generator.d.ts +334 -0
- package/lib/codegen/generator.d.ts.map +1 -0
- package/lib/codegen/generator.js +813 -0
- package/lib/codegen/generator.js.map +1 -0
- package/lib/codegen/graph.d.ts +36 -0
- package/lib/codegen/graph.d.ts.map +1 -0
- package/lib/codegen/graph.js +136 -0
- package/lib/codegen/graph.js.map +1 -0
- package/lib/codegen/graphql-client.d.ts +62 -0
- package/lib/codegen/graphql-client.d.ts.map +1 -0
- package/lib/codegen/graphql-client.js +294 -0
- package/lib/codegen/graphql-client.js.map +1 -0
- package/lib/codegen/graphql-client.test.d.ts +2 -0
- package/lib/codegen/graphql-client.test.d.ts.map +1 -0
- package/lib/codegen/graphql-client.test.js +311 -0
- package/lib/codegen/graphql-client.test.js.map +1 -0
- package/lib/codegen/graphql.d.ts +207 -0
- package/lib/codegen/graphql.d.ts.map +1 -0
- package/lib/codegen/graphql.js +799 -0
- package/lib/codegen/graphql.js.map +1 -0
- package/lib/codegen/members.d.ts +25 -0
- package/lib/codegen/members.d.ts.map +1 -0
- package/lib/codegen/members.js +55 -0
- package/lib/codegen/members.js.map +1 -0
- package/lib/codegen/naming.d.ts +29 -0
- package/lib/codegen/naming.d.ts.map +1 -0
- package/lib/codegen/naming.js +74 -0
- package/lib/codegen/naming.js.map +1 -0
- package/lib/codegen/openapi-cli.d.ts +52 -0
- package/lib/codegen/openapi-cli.d.ts.map +1 -0
- package/lib/codegen/openapi-cli.js +109 -0
- package/lib/codegen/openapi-cli.js.map +1 -0
- package/lib/codegen/openapi.d.ts +178 -0
- package/lib/codegen/openapi.d.ts.map +1 -0
- package/lib/codegen/openapi.js +1377 -0
- package/lib/codegen/openapi.js.map +1 -0
- package/lib/codegen/operations.d.ts +24 -0
- package/lib/codegen/operations.d.ts.map +1 -0
- package/lib/codegen/operations.js +56 -0
- package/lib/codegen/operations.js.map +1 -0
- package/lib/codegen/pagination.d.ts +39 -0
- package/lib/codegen/pagination.d.ts.map +1 -0
- package/lib/codegen/pagination.js +33 -0
- package/lib/codegen/pagination.js.map +1 -0
- package/lib/codegen/patches.d.ts +65 -0
- package/lib/codegen/patches.d.ts.map +1 -0
- package/lib/codegen/patches.js +236 -0
- package/lib/codegen/patches.js.map +1 -0
- package/lib/codegen/patches.test.d.ts +2 -0
- package/lib/codegen/patches.test.d.ts.map +1 -0
- package/lib/codegen/patches.test.js +105 -0
- package/lib/codegen/patches.test.js.map +1 -0
- package/lib/codegen/prelude.d.ts +15 -0
- package/lib/codegen/prelude.d.ts.map +1 -0
- package/lib/codegen/prelude.js +60 -0
- package/lib/codegen/prelude.js.map +1 -0
- package/lib/codegen/proto.d.ts +121 -0
- package/lib/codegen/proto.d.ts.map +1 -0
- package/lib/codegen/proto.js +962 -0
- package/lib/codegen/proto.js.map +1 -0
- package/lib/codegen/rewrite-operation-ids.d.ts +131 -0
- package/lib/codegen/rewrite-operation-ids.d.ts.map +1 -0
- package/lib/codegen/rewrite-operation-ids.js +1079 -0
- package/lib/codegen/rewrite-operation-ids.js.map +1 -0
- package/lib/codegen/rewrite-operation-ids.test.d.ts +2 -0
- package/lib/codegen/rewrite-operation-ids.test.d.ts.map +1 -0
- package/lib/codegen/rewrite-operation-ids.test.js +533 -0
- package/lib/codegen/rewrite-operation-ids.test.js.map +1 -0
- package/lib/codegen/spec-path.d.ts +16 -0
- package/lib/codegen/spec-path.d.ts.map +1 -0
- package/lib/codegen/spec-path.js +101 -0
- package/lib/codegen/spec-path.js.map +1 -0
- package/lib/error-category.d.ts +28 -0
- package/lib/error-category.d.ts.map +1 -0
- package/lib/error-category.js +46 -0
- package/lib/error-category.js.map +1 -0
- package/lib/errors.d.ts +1 -0
- package/lib/errors.d.ts.map +1 -1
- package/lib/errors.js +18 -13
- package/lib/errors.js.map +1 -1
- package/lib/graphql.d.ts +284 -0
- package/lib/graphql.d.ts.map +1 -0
- package/lib/graphql.fixture.d.ts +249 -0
- package/lib/graphql.fixture.d.ts.map +1 -0
- package/lib/graphql.fixture.js +240 -0
- package/lib/graphql.fixture.js.map +1 -0
- package/lib/graphql.js +718 -0
- package/lib/graphql.js.map +1 -0
- package/lib/graphql.test.d.ts +2 -0
- package/lib/graphql.test.d.ts.map +1 -0
- package/lib/graphql.test.js +780 -0
- package/lib/graphql.test.js.map +1 -0
- package/lib/graphql.types.d.ts +2 -0
- package/lib/graphql.types.d.ts.map +1 -0
- package/lib/graphql.types.js +45 -0
- package/lib/graphql.types.js.map +1 -0
- package/lib/json-patch.d.ts +30 -30
- package/lib/json-patch.d.ts.map +1 -1
- package/lib/json-patch.js +73 -107
- package/lib/json-patch.js.map +1 -1
- package/lib/pagination.d.ts +77 -51
- package/lib/pagination.d.ts.map +1 -1
- package/lib/pagination.js +162 -94
- package/lib/pagination.js.map +1 -1
- package/lib/protocol-http.d.ts +74 -0
- package/lib/protocol-http.d.ts.map +1 -0
- package/lib/protocol-http.js +590 -0
- package/lib/protocol-http.js.map +1 -0
- package/lib/protocol-http.test.d.ts +2 -0
- package/lib/protocol-http.test.d.ts.map +1 -0
- package/lib/protocol-http.test.js +88 -0
- package/lib/protocol-http.test.js.map +1 -0
- package/lib/protocol-rest.d.ts +134 -0
- package/lib/protocol-rest.d.ts.map +1 -0
- package/lib/protocol-rest.js +256 -0
- package/lib/protocol-rest.js.map +1 -0
- package/lib/retry.d.ts +8 -2
- package/lib/retry.d.ts.map +1 -1
- package/lib/retry.js +22 -16
- package/lib/retry.js.map +1 -1
- package/lib/schema.d.ts +8 -9
- package/lib/schema.d.ts.map +1 -1
- package/lib/schema.js +8 -9
- package/lib/schema.js.map +1 -1
- package/lib/trait.d.ts +174 -0
- package/lib/trait.d.ts.map +1 -0
- package/lib/trait.js +123 -0
- package/lib/trait.js.map +1 -0
- package/package.json +24 -78
- package/src/api.ts +460 -0
- package/src/category.ts +4 -4
- package/src/codegen/boolean-string-enums.test.ts +168 -0
- package/src/codegen/boolean-string-enums.ts +106 -0
- package/src/codegen/cli.ts +285 -0
- package/src/codegen/emit.ts +203 -0
- package/src/codegen/format.ts +47 -0
- package/src/codegen/generator.ts +1283 -0
- package/src/codegen/graph.ts +151 -0
- package/src/codegen/graphql-client.test.ts +386 -0
- package/src/codegen/graphql-client.ts +419 -0
- package/src/codegen/graphql.ts +1217 -0
- package/src/codegen/members.ts +71 -0
- package/src/codegen/naming.ts +86 -0
- package/src/codegen/openapi-cli.ts +182 -0
- package/src/codegen/openapi.ts +1689 -0
- package/src/codegen/operations.ts +76 -0
- package/src/codegen/pagination.ts +71 -0
- package/src/codegen/patches.test.ts +130 -0
- package/src/codegen/patches.ts +291 -0
- package/src/codegen/prelude.ts +70 -0
- package/src/codegen/proto.ts +1128 -0
- package/src/codegen/rewrite-operation-ids.test.ts +563 -0
- package/src/codegen/rewrite-operation-ids.ts +1206 -0
- package/src/codegen/spec-path.ts +115 -0
- package/src/error-category.ts +84 -0
- package/src/errors.ts +22 -25
- package/src/graphql.fixture.ts +371 -0
- package/src/graphql.test.ts +974 -0
- package/src/graphql.ts +1321 -0
- package/src/graphql.types.ts +185 -0
- package/src/json-patch.ts +95 -122
- package/src/pagination.ts +217 -146
- package/src/protocol-http.test.ts +107 -0
- package/src/protocol-http.ts +735 -0
- package/src/protocol-rest.ts +391 -0
- package/src/retry.ts +21 -22
- package/src/schema.ts +9 -10
- package/src/trait.ts +274 -0
- package/README.md +0 -30
- package/lib/client.d.ts +0 -167
- package/lib/client.d.ts.map +0 -1
- package/lib/client.js +0 -659
- package/lib/client.js.map +0 -1
- package/lib/schemas.d.ts +0 -60
- package/lib/schemas.d.ts.map +0 -1
- package/lib/schemas.js +0 -79
- package/lib/schemas.js.map +0 -1
- package/lib/sensitive.d.ts +0 -71
- package/lib/sensitive.d.ts.map +0 -1
- package/lib/sensitive.js +0 -96
- package/lib/sensitive.js.map +0 -1
- package/lib/traits.d.ts +0 -421
- package/lib/traits.d.ts.map +0 -1
- package/lib/traits.js +0 -737
- package/lib/traits.js.map +0 -1
- package/src/client.ts +0 -1177
- package/src/schemas.ts +0 -128
- package/src/sensitive.ts +0 -119
- package/src/traits.ts +0 -996
package/src/pagination.ts
CHANGED
|
@@ -2,47 +2,91 @@
|
|
|
2
2
|
* Pagination utilities for streaming through paginated API responses.
|
|
3
3
|
*
|
|
4
4
|
* Supports multiple pagination styles:
|
|
5
|
-
* - Page-based
|
|
6
|
-
* - Cursor-based
|
|
7
|
-
* - Token-based (
|
|
5
|
+
* - Page-based: page/per_page with a page number that advances
|
|
6
|
+
* - Cursor-based: cursor/limit with an opaque next-cursor string
|
|
7
|
+
* - Token-based (AWS style): NextToken/MaxResults continuation tokens
|
|
8
|
+
* - Relay (GraphQL connections): after/first with a `pageInfo` block whose
|
|
9
|
+
* `hasNextPage` — not the cursor — marks the end
|
|
10
|
+
* - Single: one-shot list endpoints that still expose the paginated surface
|
|
8
11
|
*
|
|
9
|
-
* Each SDK
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* ```ts
|
|
14
|
-
* import * as Pagination from "@distilled.cloud/core/pagination";
|
|
15
|
-
*
|
|
16
|
-
* // Page-based pagination
|
|
17
|
-
* const allPages = Pagination.paginatePages(listDatabases, { organization: "my-org" }, {
|
|
18
|
-
* inputToken: "page",
|
|
19
|
-
* outputToken: "next_page",
|
|
20
|
-
* items: "data",
|
|
21
|
-
* });
|
|
22
|
-
* ```
|
|
12
|
+
* Each SDK stores a {@link PaginatedTrait} on its operations (sourced from the
|
|
13
|
+
* `smithy.api#paginated` trait in its models) and picks a
|
|
14
|
+
* {@link PaginationStrategy}; these shared utilities handle the streaming.
|
|
15
|
+
* Ported from the distilled repo's `core/pagination`.
|
|
23
16
|
*/
|
|
24
17
|
import * as Effect from "effect/Effect";
|
|
25
18
|
import * as Stream from "effect/Stream";
|
|
26
|
-
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get a value from an object using a dot-separated path (e.g.
|
|
22
|
+
* `"resultInfo.page"`). Used for pagination traits and nested access.
|
|
23
|
+
*/
|
|
24
|
+
export const getPath = (obj: unknown, path: string): unknown => {
|
|
25
|
+
const parts = path.split(".");
|
|
26
|
+
let current: unknown = obj;
|
|
27
|
+
for (const part of parts) {
|
|
28
|
+
if (current == null || typeof current !== "object") {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
current = (current as Record<string, unknown>)[part];
|
|
32
|
+
}
|
|
33
|
+
return current;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Collect the items a dot-separated path selects, flattening arrays as it
|
|
38
|
+
* goes. Unlike {@link getPath} (which walks a single value and is used for
|
|
39
|
+
* cursors/tokens), every segment here fans out over whatever the previous one
|
|
40
|
+
* produced, so a path may cross a list:
|
|
41
|
+
*
|
|
42
|
+
* - `"items"` on `{ items: [a, b] }` → `[a, b]` (the common flat case)
|
|
43
|
+
* - `"edges.node"` on a Relay connection → every edge's `node`
|
|
44
|
+
*
|
|
45
|
+
* `null`/`undefined` links are dropped rather than propagated, so a partially
|
|
46
|
+
* null page yields the items it does have instead of nothing.
|
|
47
|
+
*/
|
|
48
|
+
export const getItems = (obj: unknown, path: string): readonly unknown[] => {
|
|
49
|
+
let current: unknown[] = [obj];
|
|
50
|
+
for (const part of path.split(".")) {
|
|
51
|
+
const next: unknown[] = [];
|
|
52
|
+
for (const value of current) {
|
|
53
|
+
if (value == null || typeof value !== "object") continue;
|
|
54
|
+
const child = (value as Record<string, unknown>)[part];
|
|
55
|
+
if (Array.isArray(child)) next.push(...child);
|
|
56
|
+
else if (child != null) next.push(child);
|
|
57
|
+
}
|
|
58
|
+
current = next;
|
|
59
|
+
}
|
|
60
|
+
return current;
|
|
61
|
+
};
|
|
27
62
|
|
|
28
63
|
// ============================================================================
|
|
29
64
|
// Pagination Trait
|
|
30
65
|
// ============================================================================
|
|
31
66
|
|
|
32
|
-
/**
|
|
33
|
-
* Pagination trait describing how to navigate between pages.
|
|
34
|
-
*/
|
|
67
|
+
/** Pagination trait describing how to navigate between pages. */
|
|
35
68
|
export interface PaginatedTrait {
|
|
36
69
|
/** Pagination strategy */
|
|
37
|
-
mode?: "token" | "page" | "cursor" | "single";
|
|
70
|
+
readonly mode?: "token" | "page" | "cursor" | "relay" | "single";
|
|
38
71
|
/** The name of the input member containing the page/cursor token */
|
|
39
|
-
inputToken?: string;
|
|
72
|
+
readonly inputToken?: string;
|
|
40
73
|
/** The path to the output member containing the next page/cursor token */
|
|
41
|
-
outputToken?: string;
|
|
42
|
-
/**
|
|
43
|
-
|
|
74
|
+
readonly outputToken?: string;
|
|
75
|
+
/**
|
|
76
|
+
* The path to the output member containing the paginated items. Segments
|
|
77
|
+
* may cross arrays — `"edges.node"` walks every edge and collects its
|
|
78
|
+
* `node` (see {@link getItems}).
|
|
79
|
+
*/
|
|
80
|
+
readonly items?: string;
|
|
44
81
|
/** The name of the input member that limits page size */
|
|
45
|
-
pageSize?: string;
|
|
82
|
+
readonly pageSize?: string;
|
|
83
|
+
/**
|
|
84
|
+
* Relay extension: the path to the boolean that says whether another page
|
|
85
|
+
* exists (`"pageInfo.hasNextPage"`). Relay connections keep returning the
|
|
86
|
+
* last page's `endCursor` after the end, so the cursor alone can't
|
|
87
|
+
* terminate traversal — see {@link paginateRelay}.
|
|
88
|
+
*/
|
|
89
|
+
readonly hasNextPage?: string;
|
|
46
90
|
}
|
|
47
91
|
|
|
48
92
|
export type PaginationStrategy = <
|
|
@@ -50,15 +94,10 @@ export type PaginationStrategy = <
|
|
|
50
94
|
Output,
|
|
51
95
|
E,
|
|
52
96
|
R,
|
|
53
|
-
RequestOptions = never,
|
|
54
97
|
>(
|
|
55
|
-
operation: (
|
|
56
|
-
input: Input,
|
|
57
|
-
requestOptions?: RequestOptions,
|
|
58
|
-
) => Effect.Effect<Output, E, R>,
|
|
98
|
+
operation: (input: Input) => Effect.Effect<Output, E, R>,
|
|
59
99
|
input: Input,
|
|
60
100
|
pagination: PaginatedTrait,
|
|
61
|
-
requestOptions?: RequestOptions,
|
|
62
101
|
) => Stream.Stream<Output, E, R>;
|
|
63
102
|
|
|
64
103
|
const missingPaginationConfig = (kind: string) => Stream.die(new Error(kind));
|
|
@@ -68,58 +107,44 @@ const missingPaginationConfig = (kind: string) => Stream.die(new Error(kind));
|
|
|
68
107
|
* means "no more pages".
|
|
69
108
|
*
|
|
70
109
|
* APIs mark the terminal page by omitting the output token, returning it
|
|
71
|
-
* as `null`, or
|
|
72
|
-
* token re-requests the first page
|
|
73
|
-
*
|
|
110
|
+
* as `null`, or — for several services (e.g. SSM, CloudWatch Logs) — as an
|
|
111
|
+
* empty string. Treating `""` as a live token re-requests the first page
|
|
112
|
+
* forever (or fails with a ValidationException). Object tokens (e.g.
|
|
113
|
+
* DynamoDB's `LastEvaluatedKey`) are always truthy and unaffected.
|
|
74
114
|
*/
|
|
75
115
|
export const isTerminalToken = (token: unknown): boolean =>
|
|
76
116
|
token === undefined || token === null || token === "";
|
|
77
117
|
|
|
78
118
|
/**
|
|
79
|
-
*
|
|
119
|
+
* Stream for single-shot list endpoints that still expose the paginated
|
|
120
|
+
* surface — emits exactly one page.
|
|
80
121
|
*/
|
|
81
|
-
export const paginateSingle: PaginationStrategy = (
|
|
82
|
-
operation,
|
|
83
|
-
input,
|
|
84
|
-
_pagination,
|
|
85
|
-
requestOptions,
|
|
86
|
-
) =>
|
|
122
|
+
export const paginateSingle: PaginationStrategy = (operation, input) =>
|
|
87
123
|
Stream.make(input).pipe(
|
|
88
|
-
Stream.mapEffect((requestPayload) =>
|
|
89
|
-
operation(requestPayload, requestOptions),
|
|
90
|
-
),
|
|
124
|
+
Stream.mapEffect((requestPayload) => operation(requestPayload)),
|
|
91
125
|
);
|
|
92
126
|
|
|
93
127
|
// ============================================================================
|
|
94
|
-
// Page-based Pagination
|
|
128
|
+
// Page-based Pagination
|
|
95
129
|
// ============================================================================
|
|
96
130
|
|
|
97
131
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* @param input - The initial input (without page parameter)
|
|
102
|
-
* @param pagination - The pagination trait configuration
|
|
103
|
-
* @returns A Stream of full page responses
|
|
132
|
+
* Stream of pages using page-number pagination. The next page is taken from
|
|
133
|
+
* `outputToken` when it advances; otherwise the page number is incremented,
|
|
134
|
+
* terminating when a page comes back with no items (or no token).
|
|
104
135
|
*/
|
|
105
136
|
export const paginatePageNumber = <
|
|
106
137
|
Input extends Record<string, unknown>,
|
|
107
138
|
Output,
|
|
108
139
|
E,
|
|
109
140
|
R,
|
|
110
|
-
RequestOptions = never,
|
|
111
141
|
>(
|
|
112
|
-
operation: (
|
|
113
|
-
|
|
114
|
-
requestOptions?: RequestOptions,
|
|
115
|
-
) => Effect.Effect<Output, E, R>,
|
|
116
|
-
input: Omit<Input, string>,
|
|
142
|
+
operation: (input: Input) => Effect.Effect<Output, E, R>,
|
|
143
|
+
input: Input,
|
|
117
144
|
pagination: PaginatedTrait,
|
|
118
|
-
requestOptions?: RequestOptions,
|
|
119
145
|
): Stream.Stream<Output, E, R> => {
|
|
120
146
|
const inputToken = pagination.inputToken;
|
|
121
147
|
const outputToken = pagination.outputToken;
|
|
122
|
-
const inputRecord = input as Record<string, unknown>;
|
|
123
148
|
if (!inputToken || !outputToken) {
|
|
124
149
|
return missingPaginationConfig(
|
|
125
150
|
"Page-number pagination requires inputToken and outputToken",
|
|
@@ -127,34 +152,25 @@ export const paginatePageNumber = <
|
|
|
127
152
|
}
|
|
128
153
|
type State = { page: number; done: boolean };
|
|
129
154
|
const startPage =
|
|
130
|
-
typeof
|
|
131
|
-
? (inputRecord[inputToken] as number)
|
|
132
|
-
: 1;
|
|
155
|
+
typeof input[inputToken] === "number" ? (input[inputToken] as number) : 1;
|
|
133
156
|
|
|
134
|
-
|
|
157
|
+
return Stream.unfold({ page: startPage, done: false } as State, (state) =>
|
|
135
158
|
Effect.gen(function* () {
|
|
136
|
-
if (state.done)
|
|
137
|
-
return undefined;
|
|
138
|
-
}
|
|
159
|
+
if (state.done) return undefined;
|
|
139
160
|
|
|
140
|
-
const requestPayload = {
|
|
141
|
-
|
|
142
|
-
[inputToken]: state.page,
|
|
143
|
-
} as Input;
|
|
144
|
-
|
|
145
|
-
const response = yield* operation(requestPayload, requestOptions);
|
|
161
|
+
const requestPayload = { ...input, [inputToken]: state.page } as Input;
|
|
162
|
+
const response = yield* operation(requestPayload);
|
|
146
163
|
|
|
147
164
|
const nextPage = getPath(response, outputToken) as
|
|
148
165
|
| number
|
|
149
166
|
| null
|
|
150
167
|
| undefined;
|
|
151
168
|
|
|
152
|
-
// Some APIs report the CURRENT page at `outputToken` rather than
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
// absent).
|
|
169
|
+
// Some APIs report the CURRENT page at `outputToken` rather than the
|
|
170
|
+
// next one (e.g. Cloudflare's `result_info.page`). Taking that value as
|
|
171
|
+
// the next page re-requests the same page forever. Only accept an
|
|
172
|
+
// *advancing* page number; otherwise advance by one and terminate when
|
|
173
|
+
// a page comes back with no items (or the token is absent).
|
|
158
174
|
const items = pagination.items
|
|
159
175
|
? (getPath(response, pagination.items) as
|
|
160
176
|
| readonly unknown[]
|
|
@@ -173,41 +189,30 @@ export const paginatePageNumber = <
|
|
|
173
189
|
};
|
|
174
190
|
|
|
175
191
|
return [response, nextState] as const;
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
return Stream.unfold({ page: startPage, done: false } as State, unfoldFn);
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
179
194
|
};
|
|
180
195
|
|
|
181
196
|
// ============================================================================
|
|
182
|
-
// Cursor-based Pagination
|
|
197
|
+
// Cursor-based Pagination
|
|
183
198
|
// ============================================================================
|
|
184
199
|
|
|
185
200
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* @param operation - The paginated operation to call
|
|
189
|
-
* @param input - The initial input (without cursor parameter)
|
|
190
|
-
* @param pagination - The pagination trait configuration
|
|
191
|
-
* @returns A Stream of full page responses
|
|
201
|
+
* Stream of pages using cursor-based pagination — follow `outputToken`
|
|
202
|
+
* cursors until one comes back absent.
|
|
192
203
|
*/
|
|
193
204
|
export const paginateCursor = <
|
|
194
205
|
Input extends Record<string, unknown>,
|
|
195
206
|
Output,
|
|
196
207
|
E,
|
|
197
208
|
R,
|
|
198
|
-
RequestOptions = never,
|
|
199
209
|
>(
|
|
200
|
-
operation: (
|
|
201
|
-
|
|
202
|
-
requestOptions?: RequestOptions,
|
|
203
|
-
) => Effect.Effect<Output, E, R>,
|
|
204
|
-
input: Omit<Input, string>,
|
|
210
|
+
operation: (input: Input) => Effect.Effect<Output, E, R>,
|
|
211
|
+
input: Input,
|
|
205
212
|
pagination: PaginatedTrait,
|
|
206
|
-
requestOptions?: RequestOptions,
|
|
207
213
|
): Stream.Stream<Output, E, R> => {
|
|
208
214
|
const inputToken = pagination.inputToken;
|
|
209
215
|
const outputToken = pagination.outputToken;
|
|
210
|
-
const inputRecord = input as Record<string, unknown>;
|
|
211
216
|
if (!inputToken || !outputToken) {
|
|
212
217
|
return missingPaginationConfig(
|
|
213
218
|
"Cursor pagination requires inputToken and outputToken",
|
|
@@ -215,22 +220,20 @@ export const paginateCursor = <
|
|
|
215
220
|
}
|
|
216
221
|
type State = { cursor: string | undefined; done: boolean };
|
|
217
222
|
const startCursor =
|
|
218
|
-
typeof
|
|
219
|
-
? (
|
|
223
|
+
typeof input[inputToken] === "string"
|
|
224
|
+
? (input[inputToken] as string)
|
|
220
225
|
: undefined;
|
|
221
226
|
|
|
222
|
-
|
|
227
|
+
return Stream.unfold({ cursor: startCursor, done: false } as State, (state) =>
|
|
223
228
|
Effect.gen(function* () {
|
|
224
|
-
if (state.done)
|
|
225
|
-
return undefined;
|
|
226
|
-
}
|
|
229
|
+
if (state.done) return undefined;
|
|
227
230
|
|
|
228
231
|
const requestPayload = {
|
|
229
232
|
...input,
|
|
230
233
|
...(state.cursor ? { [inputToken]: state.cursor } : {}),
|
|
231
234
|
} as Input;
|
|
232
235
|
|
|
233
|
-
const response = yield* operation(requestPayload
|
|
236
|
+
const response = yield* operation(requestPayload);
|
|
234
237
|
|
|
235
238
|
const nextCursor = getPath(response, outputToken) as
|
|
236
239
|
| string
|
|
@@ -243,9 +246,8 @@ export const paginateCursor = <
|
|
|
243
246
|
};
|
|
244
247
|
|
|
245
248
|
return [response, nextState] as const;
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
return Stream.unfold({ cursor: startCursor, done: false } as State, unfoldFn);
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
249
251
|
};
|
|
250
252
|
|
|
251
253
|
// ============================================================================
|
|
@@ -253,54 +255,39 @@ export const paginateCursor = <
|
|
|
253
255
|
// ============================================================================
|
|
254
256
|
|
|
255
257
|
/**
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
* @param operation - The paginated operation to call
|
|
259
|
-
* @param input - The initial input
|
|
260
|
-
* @param pagination - The pagination trait configuration
|
|
261
|
-
* @returns A Stream of full page responses
|
|
258
|
+
* Stream of pages using token-based pagination — pass `outputToken` back as
|
|
259
|
+
* `inputToken` until it comes back absent.
|
|
262
260
|
*/
|
|
263
261
|
export const paginateToken = <
|
|
264
262
|
Input extends Record<string, unknown>,
|
|
265
263
|
Output,
|
|
266
264
|
E,
|
|
267
265
|
R,
|
|
268
|
-
RequestOptions = never,
|
|
269
266
|
>(
|
|
270
|
-
operation: (
|
|
271
|
-
input: Input,
|
|
272
|
-
requestOptions?: RequestOptions,
|
|
273
|
-
) => Effect.Effect<Output, E, R>,
|
|
267
|
+
operation: (input: Input) => Effect.Effect<Output, E, R>,
|
|
274
268
|
input: Input,
|
|
275
269
|
pagination: PaginatedTrait,
|
|
276
|
-
requestOptions?: RequestOptions,
|
|
277
270
|
): Stream.Stream<Output, E, R> => {
|
|
278
271
|
const inputToken = pagination.inputToken;
|
|
279
272
|
const outputToken = pagination.outputToken;
|
|
280
|
-
const inputRecord = input as Record<string, unknown>;
|
|
281
273
|
if (!inputToken || !outputToken) {
|
|
282
274
|
return missingPaginationConfig(
|
|
283
275
|
"Token pagination requires inputToken and outputToken",
|
|
284
276
|
);
|
|
285
277
|
}
|
|
286
278
|
type State = { token: unknown; done: boolean };
|
|
287
|
-
const startToken =
|
|
279
|
+
const startToken = input[inputToken];
|
|
288
280
|
|
|
289
|
-
|
|
281
|
+
return Stream.unfold({ token: startToken, done: false } as State, (state) =>
|
|
290
282
|
Effect.gen(function* () {
|
|
291
|
-
if (state.done)
|
|
292
|
-
return undefined;
|
|
293
|
-
}
|
|
283
|
+
if (state.done) return undefined;
|
|
294
284
|
|
|
295
285
|
const requestPayload =
|
|
296
286
|
state.token !== undefined
|
|
297
|
-
? { ...input, [inputToken]: state.token }
|
|
287
|
+
? ({ ...input, [inputToken]: state.token } as Input)
|
|
298
288
|
: input;
|
|
299
289
|
|
|
300
|
-
const response = yield* operation(
|
|
301
|
-
requestPayload as Input,
|
|
302
|
-
requestOptions,
|
|
303
|
-
);
|
|
290
|
+
const response = yield* operation(requestPayload);
|
|
304
291
|
|
|
305
292
|
const nextToken = getPath(response, outputToken);
|
|
306
293
|
|
|
@@ -310,34 +297,118 @@ export const paginateToken = <
|
|
|
310
297
|
};
|
|
311
298
|
|
|
312
299
|
return [response, nextState] as const;
|
|
313
|
-
})
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// ============================================================================
|
|
305
|
+
// Relay Pagination (GraphQL connections)
|
|
306
|
+
// ============================================================================
|
|
314
307
|
|
|
315
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Stream of pages over a Relay connection — pass `pageInfo.endCursor` back as
|
|
310
|
+
* `after` for as long as `pageInfo.hasNextPage` is true.
|
|
311
|
+
*
|
|
312
|
+
* Relay is cursor pagination with one twist that breaks
|
|
313
|
+
* {@link paginateCursor}: the terminal page still carries an `endCursor` (it
|
|
314
|
+
* points at the last edge, not at "nothing left"). Only `hasNextPage`
|
|
315
|
+
* distinguishes "more to fetch" from "that was everything", so this strategy
|
|
316
|
+
* reads the boolean and treats the cursor as a pure position marker. A
|
|
317
|
+
* connection that omits `pageInfo` entirely (or returns an empty page) also
|
|
318
|
+
* terminates, so a malformed response can't spin forever.
|
|
319
|
+
*/
|
|
320
|
+
export const paginateRelay = <
|
|
321
|
+
Input extends Record<string, unknown>,
|
|
322
|
+
Output,
|
|
323
|
+
E,
|
|
324
|
+
R,
|
|
325
|
+
>(
|
|
326
|
+
operation: (input: Input) => Effect.Effect<Output, E, R>,
|
|
327
|
+
input: Input,
|
|
328
|
+
pagination: PaginatedTrait,
|
|
329
|
+
): Stream.Stream<Output, E, R> => {
|
|
330
|
+
const inputToken = pagination.inputToken;
|
|
331
|
+
const outputToken = pagination.outputToken;
|
|
332
|
+
if (!inputToken || !outputToken) {
|
|
333
|
+
return missingPaginationConfig(
|
|
334
|
+
"Relay pagination requires inputToken and outputToken",
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
// `pageInfo.endCursor` → `pageInfo.hasNextPage` when the trait doesn't say.
|
|
338
|
+
const hasNextPath =
|
|
339
|
+
pagination.hasNextPage ??
|
|
340
|
+
`${outputToken.split(".").slice(0, -1).concat("hasNextPage").join(".")}`;
|
|
341
|
+
|
|
342
|
+
type State = { cursor: string | undefined; done: boolean };
|
|
343
|
+
const startCursor =
|
|
344
|
+
typeof input[inputToken] === "string"
|
|
345
|
+
? (input[inputToken] as string)
|
|
346
|
+
: undefined;
|
|
347
|
+
|
|
348
|
+
return Stream.unfold({ cursor: startCursor, done: false } as State, (state) =>
|
|
349
|
+
Effect.gen(function* () {
|
|
350
|
+
if (state.done) return undefined;
|
|
351
|
+
|
|
352
|
+
const requestPayload = {
|
|
353
|
+
...input,
|
|
354
|
+
...(state.cursor ? { [inputToken]: state.cursor } : {}),
|
|
355
|
+
} as Input;
|
|
356
|
+
|
|
357
|
+
const response = yield* operation(requestPayload);
|
|
358
|
+
|
|
359
|
+
const nextCursor = getPath(response, outputToken) as
|
|
360
|
+
| string
|
|
361
|
+
| null
|
|
362
|
+
| undefined;
|
|
363
|
+
const hasNext = getPath(response, hasNextPath) === true;
|
|
364
|
+
// An empty page means the connection is exhausted regardless of what
|
|
365
|
+
// `hasNextPage` claims — re-requesting the same cursor would loop.
|
|
366
|
+
const emptyPage =
|
|
367
|
+
pagination.items !== undefined &&
|
|
368
|
+
getItems(response, pagination.items).length === 0;
|
|
369
|
+
// A connection that keeps returning the same `endCursor` with
|
|
370
|
+
// `hasNextPage: true` (Railway `projects` has done this) would
|
|
371
|
+
// otherwise paginate forever.
|
|
372
|
+
const stuckCursor =
|
|
373
|
+
state.cursor !== undefined &&
|
|
374
|
+
nextCursor !== undefined &&
|
|
375
|
+
nextCursor !== null &&
|
|
376
|
+
nextCursor === state.cursor;
|
|
377
|
+
|
|
378
|
+
const nextState: State = {
|
|
379
|
+
cursor: nextCursor ?? undefined,
|
|
380
|
+
done:
|
|
381
|
+
!hasNext || isTerminalToken(nextCursor) || emptyPage || stuckCursor,
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
return [response, nextState] as const;
|
|
385
|
+
}),
|
|
386
|
+
);
|
|
316
387
|
};
|
|
317
388
|
|
|
318
389
|
/**
|
|
319
|
-
* Shared default pagination dispatcher for SDKs that use generic
|
|
390
|
+
* Shared default pagination dispatcher for SDKs that use generic
|
|
391
|
+
* token/cursor/page traversal.
|
|
320
392
|
*/
|
|
321
393
|
export const paginateWithDefaults: PaginationStrategy = (
|
|
322
394
|
operation,
|
|
323
395
|
input,
|
|
324
396
|
pagination,
|
|
325
|
-
requestOptions,
|
|
326
397
|
) => {
|
|
327
398
|
const mode = pagination.mode ?? "token";
|
|
328
399
|
|
|
329
400
|
switch (mode) {
|
|
330
401
|
case "page":
|
|
331
|
-
return paginatePageNumber(operation, input, pagination
|
|
402
|
+
return paginatePageNumber(operation, input, pagination);
|
|
332
403
|
case "cursor":
|
|
333
|
-
return paginateCursor(operation, input, pagination
|
|
404
|
+
return paginateCursor(operation, input, pagination);
|
|
405
|
+
case "relay":
|
|
406
|
+
return paginateRelay(operation, input, pagination);
|
|
334
407
|
case "single":
|
|
335
|
-
return
|
|
336
|
-
"Single-page pagination requires a provider-specific pagination strategy",
|
|
337
|
-
);
|
|
408
|
+
return paginateSingle(operation, input, pagination);
|
|
338
409
|
case "token":
|
|
339
410
|
default:
|
|
340
|
-
return paginateToken(operation, input, pagination
|
|
411
|
+
return paginateToken(operation, input, pagination);
|
|
341
412
|
}
|
|
342
413
|
};
|
|
343
414
|
|
|
@@ -349,7 +420,8 @@ export const paginateWithDefaults: PaginationStrategy = (
|
|
|
349
420
|
* Extracts individual items from a page stream.
|
|
350
421
|
*
|
|
351
422
|
* @param pages - A stream of page responses
|
|
352
|
-
* @param itemsPath - Dot-separated path to the items
|
|
423
|
+
* @param itemsPath - Dot-separated path to the items in the page; segments may
|
|
424
|
+
* cross arrays (see {@link getItems})
|
|
353
425
|
* @returns A Stream of individual items
|
|
354
426
|
*/
|
|
355
427
|
export const extractItems = <Output, Item, E, R>(
|
|
@@ -357,8 +429,7 @@ export const extractItems = <Output, Item, E, R>(
|
|
|
357
429
|
itemsPath: string,
|
|
358
430
|
): Stream.Stream<Item, E, R> =>
|
|
359
431
|
pages.pipe(
|
|
360
|
-
Stream.flatMap((page) =>
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}),
|
|
432
|
+
Stream.flatMap((page) =>
|
|
433
|
+
Stream.fromIterable(getItems(page, itemsPath) as readonly Item[]),
|
|
434
|
+
),
|
|
364
435
|
);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildRequest, mapKeys } from "./protocol-http.ts";
|
|
3
|
+
import * as S from "./schema.ts";
|
|
4
|
+
import * as T from "./trait.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `T.StringEncoded()`: the member's TS type stays natural (boolean) but the
|
|
8
|
+
* wire carries its string spelling, for APIs that document the field as the
|
|
9
|
+
* enum `"true" | "false"` rather than a JSON boolean.
|
|
10
|
+
*/
|
|
11
|
+
const JsonInput = S.Struct({
|
|
12
|
+
flag: S.optional(S.Boolean.pipe(T.Body("flag"), T.StringEncoded())),
|
|
13
|
+
plain: S.optional(S.Boolean.pipe(T.Body("plain"))),
|
|
14
|
+
nullable: S.optional(
|
|
15
|
+
S.NullOr(S.Boolean).pipe(T.Body("nullable"), T.StringEncoded()),
|
|
16
|
+
),
|
|
17
|
+
flags: S.optional(
|
|
18
|
+
S.Array(S.Boolean).pipe(T.Body("flags"), T.StringEncoded()),
|
|
19
|
+
),
|
|
20
|
+
}).pipe(T.Http({ method: "POST", uri: "/things" }));
|
|
21
|
+
|
|
22
|
+
const jsonBodyOf = (input: unknown): unknown => {
|
|
23
|
+
const request = buildRequest({
|
|
24
|
+
input,
|
|
25
|
+
inputAst: JsonInput.ast,
|
|
26
|
+
baseUrl: "https://example.test",
|
|
27
|
+
});
|
|
28
|
+
const body = request.body as { readonly body?: string };
|
|
29
|
+
return JSON.parse(body.body ?? "{}");
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe("StringEncoded members", () => {
|
|
33
|
+
test("booleans serialize as their string spelling", () => {
|
|
34
|
+
expect(jsonBodyOf({ flag: true, plain: true })).toEqual({
|
|
35
|
+
flag: "true",
|
|
36
|
+
plain: true,
|
|
37
|
+
});
|
|
38
|
+
expect(jsonBodyOf({ flag: false, plain: false })).toEqual({
|
|
39
|
+
flag: "false",
|
|
40
|
+
plain: false,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a list stringifies element-wise", () => {
|
|
45
|
+
expect(jsonBodyOf({ flags: [true, false] })).toEqual({
|
|
46
|
+
flags: ["true", "false"],
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("null stays null and an omitted member stays omitted", () => {
|
|
51
|
+
expect(jsonBodyOf({ nullable: null })).toEqual({ nullable: null });
|
|
52
|
+
expect(jsonBodyOf({})).toEqual({});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("UnionCases decoding", () => {
|
|
57
|
+
const cases = [
|
|
58
|
+
["id", "type", "zoneName"],
|
|
59
|
+
["id", "type", "accountName"],
|
|
60
|
+
];
|
|
61
|
+
const merged = {
|
|
62
|
+
id: "1",
|
|
63
|
+
type: "account",
|
|
64
|
+
zoneName: "zone-a",
|
|
65
|
+
accountName: "acct-a",
|
|
66
|
+
};
|
|
67
|
+
const decode = (schema: S.Schema<unknown>, value: unknown) =>
|
|
68
|
+
mapKeys(schema.ast, value, "decode");
|
|
69
|
+
|
|
70
|
+
test("the discriminator picks the case key sets cannot tell apart", () => {
|
|
71
|
+
const schema = S.Unknown.pipe(
|
|
72
|
+
T.UnionCases(cases, { key: "type", values: ["zone", "account"] }),
|
|
73
|
+
);
|
|
74
|
+
expect(decode(schema, merged)).toEqual({
|
|
75
|
+
id: "1",
|
|
76
|
+
type: "account",
|
|
77
|
+
accountName: "acct-a",
|
|
78
|
+
});
|
|
79
|
+
expect(decode(schema, { ...merged, type: "zone" })).toEqual({
|
|
80
|
+
id: "1",
|
|
81
|
+
type: "zone",
|
|
82
|
+
zoneName: "zone-a",
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("an unknown tag falls back to key-set scoring", () => {
|
|
87
|
+
const schema = S.Unknown.pipe(
|
|
88
|
+
T.UnionCases(cases, { key: "type", values: ["zone", "account"] }),
|
|
89
|
+
);
|
|
90
|
+
expect(
|
|
91
|
+
decode(schema, { ...merged, type: "other", accountName: null }),
|
|
92
|
+
).toEqual({
|
|
93
|
+
id: "1",
|
|
94
|
+
type: "other",
|
|
95
|
+
zoneName: "zone-a",
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("without a discriminator the best-explaining case wins", () => {
|
|
100
|
+
const schema = S.Unknown.pipe(T.UnionCases(cases));
|
|
101
|
+
expect(decode(schema, { ...merged, zoneName: null })).toEqual({
|
|
102
|
+
id: "1",
|
|
103
|
+
type: "account",
|
|
104
|
+
accountName: "acct-a",
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|