@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.
Files changed (216) hide show
  1. package/LICENSE +201 -0
  2. package/lib/api.d.ts +165 -0
  3. package/lib/api.d.ts.map +1 -0
  4. package/lib/api.js +190 -0
  5. package/lib/api.js.map +1 -0
  6. package/lib/category.d.ts +4 -4
  7. package/lib/category.js +4 -4
  8. package/lib/codegen/boolean-string-enums.d.ts +36 -0
  9. package/lib/codegen/boolean-string-enums.d.ts.map +1 -0
  10. package/lib/codegen/boolean-string-enums.js +94 -0
  11. package/lib/codegen/boolean-string-enums.js.map +1 -0
  12. package/lib/codegen/boolean-string-enums.test.d.ts +2 -0
  13. package/lib/codegen/boolean-string-enums.test.d.ts.map +1 -0
  14. package/lib/codegen/boolean-string-enums.test.js +147 -0
  15. package/lib/codegen/boolean-string-enums.test.js.map +1 -0
  16. package/lib/codegen/cli.d.ts +79 -0
  17. package/lib/codegen/cli.d.ts.map +1 -0
  18. package/lib/codegen/cli.js +149 -0
  19. package/lib/codegen/cli.js.map +1 -0
  20. package/lib/codegen/emit.d.ts +125 -0
  21. package/lib/codegen/emit.d.ts.map +1 -0
  22. package/lib/codegen/emit.js +101 -0
  23. package/lib/codegen/emit.js.map +1 -0
  24. package/lib/codegen/format.d.ts +23 -0
  25. package/lib/codegen/format.d.ts.map +1 -0
  26. package/lib/codegen/format.js +28 -0
  27. package/lib/codegen/format.js.map +1 -0
  28. package/lib/codegen/generator.d.ts +334 -0
  29. package/lib/codegen/generator.d.ts.map +1 -0
  30. package/lib/codegen/generator.js +813 -0
  31. package/lib/codegen/generator.js.map +1 -0
  32. package/lib/codegen/graph.d.ts +36 -0
  33. package/lib/codegen/graph.d.ts.map +1 -0
  34. package/lib/codegen/graph.js +136 -0
  35. package/lib/codegen/graph.js.map +1 -0
  36. package/lib/codegen/graphql-client.d.ts +62 -0
  37. package/lib/codegen/graphql-client.d.ts.map +1 -0
  38. package/lib/codegen/graphql-client.js +294 -0
  39. package/lib/codegen/graphql-client.js.map +1 -0
  40. package/lib/codegen/graphql-client.test.d.ts +2 -0
  41. package/lib/codegen/graphql-client.test.d.ts.map +1 -0
  42. package/lib/codegen/graphql-client.test.js +311 -0
  43. package/lib/codegen/graphql-client.test.js.map +1 -0
  44. package/lib/codegen/graphql.d.ts +207 -0
  45. package/lib/codegen/graphql.d.ts.map +1 -0
  46. package/lib/codegen/graphql.js +799 -0
  47. package/lib/codegen/graphql.js.map +1 -0
  48. package/lib/codegen/members.d.ts +25 -0
  49. package/lib/codegen/members.d.ts.map +1 -0
  50. package/lib/codegen/members.js +55 -0
  51. package/lib/codegen/members.js.map +1 -0
  52. package/lib/codegen/naming.d.ts +29 -0
  53. package/lib/codegen/naming.d.ts.map +1 -0
  54. package/lib/codegen/naming.js +74 -0
  55. package/lib/codegen/naming.js.map +1 -0
  56. package/lib/codegen/openapi-cli.d.ts +52 -0
  57. package/lib/codegen/openapi-cli.d.ts.map +1 -0
  58. package/lib/codegen/openapi-cli.js +109 -0
  59. package/lib/codegen/openapi-cli.js.map +1 -0
  60. package/lib/codegen/openapi.d.ts +178 -0
  61. package/lib/codegen/openapi.d.ts.map +1 -0
  62. package/lib/codegen/openapi.js +1377 -0
  63. package/lib/codegen/openapi.js.map +1 -0
  64. package/lib/codegen/operations.d.ts +24 -0
  65. package/lib/codegen/operations.d.ts.map +1 -0
  66. package/lib/codegen/operations.js +56 -0
  67. package/lib/codegen/operations.js.map +1 -0
  68. package/lib/codegen/pagination.d.ts +39 -0
  69. package/lib/codegen/pagination.d.ts.map +1 -0
  70. package/lib/codegen/pagination.js +33 -0
  71. package/lib/codegen/pagination.js.map +1 -0
  72. package/lib/codegen/patches.d.ts +65 -0
  73. package/lib/codegen/patches.d.ts.map +1 -0
  74. package/lib/codegen/patches.js +236 -0
  75. package/lib/codegen/patches.js.map +1 -0
  76. package/lib/codegen/patches.test.d.ts +2 -0
  77. package/lib/codegen/patches.test.d.ts.map +1 -0
  78. package/lib/codegen/patches.test.js +105 -0
  79. package/lib/codegen/patches.test.js.map +1 -0
  80. package/lib/codegen/prelude.d.ts +15 -0
  81. package/lib/codegen/prelude.d.ts.map +1 -0
  82. package/lib/codegen/prelude.js +60 -0
  83. package/lib/codegen/prelude.js.map +1 -0
  84. package/lib/codegen/proto.d.ts +121 -0
  85. package/lib/codegen/proto.d.ts.map +1 -0
  86. package/lib/codegen/proto.js +962 -0
  87. package/lib/codegen/proto.js.map +1 -0
  88. package/lib/codegen/rewrite-operation-ids.d.ts +131 -0
  89. package/lib/codegen/rewrite-operation-ids.d.ts.map +1 -0
  90. package/lib/codegen/rewrite-operation-ids.js +1079 -0
  91. package/lib/codegen/rewrite-operation-ids.js.map +1 -0
  92. package/lib/codegen/rewrite-operation-ids.test.d.ts +2 -0
  93. package/lib/codegen/rewrite-operation-ids.test.d.ts.map +1 -0
  94. package/lib/codegen/rewrite-operation-ids.test.js +533 -0
  95. package/lib/codegen/rewrite-operation-ids.test.js.map +1 -0
  96. package/lib/codegen/spec-path.d.ts +16 -0
  97. package/lib/codegen/spec-path.d.ts.map +1 -0
  98. package/lib/codegen/spec-path.js +101 -0
  99. package/lib/codegen/spec-path.js.map +1 -0
  100. package/lib/error-category.d.ts +28 -0
  101. package/lib/error-category.d.ts.map +1 -0
  102. package/lib/error-category.js +46 -0
  103. package/lib/error-category.js.map +1 -0
  104. package/lib/errors.d.ts +1 -0
  105. package/lib/errors.d.ts.map +1 -1
  106. package/lib/errors.js +18 -13
  107. package/lib/errors.js.map +1 -1
  108. package/lib/graphql.d.ts +284 -0
  109. package/lib/graphql.d.ts.map +1 -0
  110. package/lib/graphql.fixture.d.ts +249 -0
  111. package/lib/graphql.fixture.d.ts.map +1 -0
  112. package/lib/graphql.fixture.js +240 -0
  113. package/lib/graphql.fixture.js.map +1 -0
  114. package/lib/graphql.js +718 -0
  115. package/lib/graphql.js.map +1 -0
  116. package/lib/graphql.test.d.ts +2 -0
  117. package/lib/graphql.test.d.ts.map +1 -0
  118. package/lib/graphql.test.js +780 -0
  119. package/lib/graphql.test.js.map +1 -0
  120. package/lib/graphql.types.d.ts +2 -0
  121. package/lib/graphql.types.d.ts.map +1 -0
  122. package/lib/graphql.types.js +45 -0
  123. package/lib/graphql.types.js.map +1 -0
  124. package/lib/json-patch.d.ts +30 -30
  125. package/lib/json-patch.d.ts.map +1 -1
  126. package/lib/json-patch.js +73 -107
  127. package/lib/json-patch.js.map +1 -1
  128. package/lib/pagination.d.ts +77 -51
  129. package/lib/pagination.d.ts.map +1 -1
  130. package/lib/pagination.js +162 -94
  131. package/lib/pagination.js.map +1 -1
  132. package/lib/protocol-http.d.ts +74 -0
  133. package/lib/protocol-http.d.ts.map +1 -0
  134. package/lib/protocol-http.js +590 -0
  135. package/lib/protocol-http.js.map +1 -0
  136. package/lib/protocol-http.test.d.ts +2 -0
  137. package/lib/protocol-http.test.d.ts.map +1 -0
  138. package/lib/protocol-http.test.js +88 -0
  139. package/lib/protocol-http.test.js.map +1 -0
  140. package/lib/protocol-rest.d.ts +134 -0
  141. package/lib/protocol-rest.d.ts.map +1 -0
  142. package/lib/protocol-rest.js +256 -0
  143. package/lib/protocol-rest.js.map +1 -0
  144. package/lib/retry.d.ts +8 -2
  145. package/lib/retry.d.ts.map +1 -1
  146. package/lib/retry.js +22 -16
  147. package/lib/retry.js.map +1 -1
  148. package/lib/schema.d.ts +8 -9
  149. package/lib/schema.d.ts.map +1 -1
  150. package/lib/schema.js +8 -9
  151. package/lib/schema.js.map +1 -1
  152. package/lib/trait.d.ts +174 -0
  153. package/lib/trait.d.ts.map +1 -0
  154. package/lib/trait.js +123 -0
  155. package/lib/trait.js.map +1 -0
  156. package/package.json +24 -78
  157. package/src/api.ts +460 -0
  158. package/src/category.ts +4 -4
  159. package/src/codegen/boolean-string-enums.test.ts +168 -0
  160. package/src/codegen/boolean-string-enums.ts +106 -0
  161. package/src/codegen/cli.ts +285 -0
  162. package/src/codegen/emit.ts +203 -0
  163. package/src/codegen/format.ts +47 -0
  164. package/src/codegen/generator.ts +1283 -0
  165. package/src/codegen/graph.ts +151 -0
  166. package/src/codegen/graphql-client.test.ts +386 -0
  167. package/src/codegen/graphql-client.ts +419 -0
  168. package/src/codegen/graphql.ts +1217 -0
  169. package/src/codegen/members.ts +71 -0
  170. package/src/codegen/naming.ts +86 -0
  171. package/src/codegen/openapi-cli.ts +182 -0
  172. package/src/codegen/openapi.ts +1689 -0
  173. package/src/codegen/operations.ts +76 -0
  174. package/src/codegen/pagination.ts +71 -0
  175. package/src/codegen/patches.test.ts +130 -0
  176. package/src/codegen/patches.ts +291 -0
  177. package/src/codegen/prelude.ts +70 -0
  178. package/src/codegen/proto.ts +1128 -0
  179. package/src/codegen/rewrite-operation-ids.test.ts +563 -0
  180. package/src/codegen/rewrite-operation-ids.ts +1206 -0
  181. package/src/codegen/spec-path.ts +115 -0
  182. package/src/error-category.ts +84 -0
  183. package/src/errors.ts +22 -25
  184. package/src/graphql.fixture.ts +371 -0
  185. package/src/graphql.test.ts +974 -0
  186. package/src/graphql.ts +1321 -0
  187. package/src/graphql.types.ts +185 -0
  188. package/src/json-patch.ts +95 -122
  189. package/src/pagination.ts +217 -146
  190. package/src/protocol-http.test.ts +107 -0
  191. package/src/protocol-http.ts +735 -0
  192. package/src/protocol-rest.ts +391 -0
  193. package/src/retry.ts +21 -22
  194. package/src/schema.ts +9 -10
  195. package/src/trait.ts +274 -0
  196. package/README.md +0 -30
  197. package/lib/client.d.ts +0 -167
  198. package/lib/client.d.ts.map +0 -1
  199. package/lib/client.js +0 -659
  200. package/lib/client.js.map +0 -1
  201. package/lib/schemas.d.ts +0 -60
  202. package/lib/schemas.d.ts.map +0 -1
  203. package/lib/schemas.js +0 -79
  204. package/lib/schemas.js.map +0 -1
  205. package/lib/sensitive.d.ts +0 -71
  206. package/lib/sensitive.d.ts.map +0 -1
  207. package/lib/sensitive.js +0 -96
  208. package/lib/sensitive.js.map +0 -1
  209. package/lib/traits.d.ts +0 -421
  210. package/lib/traits.d.ts.map +0 -1
  211. package/lib/traits.js +0 -737
  212. package/lib/traits.js.map +0 -1
  213. package/src/client.ts +0 -1177
  214. package/src/schemas.ts +0 -128
  215. package/src/sensitive.ts +0 -119
  216. 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 (e.g., PlanetScale): page/per_page with next_page number
6
- * - Cursor-based (e.g., Neon): cursor/limit with next cursor string
7
- * - Token-based (e.g., AWS): NextToken/MaxResults with continuation tokens
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 defines its own pagination trait configuration, and these
10
- * shared utilities handle the streaming logic.
11
- *
12
- * @example
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
- import { getPath } from "./traits.ts";
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
- /** The path to the output member containing the paginated items */
43
- items?: string;
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 returning it as an empty string. Treating `""` as a live
72
- * token re-requests the first page forever (the request builders skip
73
- * falsy cursors, so the same page is fetched in an infinite loop).
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
- * Creates a stream for single-shot list endpoints that still expose the paginated API surface.
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 (PlanetScale style)
128
+ // Page-based Pagination
95
129
  // ============================================================================
96
130
 
97
131
  /**
98
- * Creates a stream of pages using page-number pagination.
99
- *
100
- * @param operation - The paginated operation to call
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
- input: Input,
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 inputRecord[inputToken] === "number"
131
- ? (inputRecord[inputToken] as number)
132
- : 1;
155
+ typeof input[inputToken] === "number" ? (input[inputToken] as number) : 1;
133
156
 
134
- const unfoldFn = (state: State) =>
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
- ...input,
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
- // the next one (e.g. Cloudflare's `result_info.page`). Taking that
154
- // value as the next page re-requests the same page forever. Only
155
- // accept an *advancing* page number; otherwise advance by one and
156
- // terminate when a page comes back with no items (or the token is
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 (Neon style)
197
+ // Cursor-based Pagination
183
198
  // ============================================================================
184
199
 
185
200
  /**
186
- * Creates a stream of pages using cursor-based pagination.
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
- input: Input,
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 inputRecord[inputToken] === "string"
219
- ? (inputRecord[inputToken] as string)
223
+ typeof input[inputToken] === "string"
224
+ ? (input[inputToken] as string)
220
225
  : undefined;
221
226
 
222
- const unfoldFn = (state: State) =>
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, requestOptions);
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
- * Creates a stream of pages using token-based pagination.
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 = inputRecord[inputToken];
279
+ const startToken = input[inputToken];
288
280
 
289
- const unfoldFn = (state: State) =>
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
- return Stream.unfold({ token: startToken, done: false } as State, unfoldFn);
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 token/cursor/page traversal.
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, requestOptions);
402
+ return paginatePageNumber(operation, input, pagination);
332
403
  case "cursor":
333
- return paginateCursor(operation, input, pagination, requestOptions);
404
+ return paginateCursor(operation, input, pagination);
405
+ case "relay":
406
+ return paginateRelay(operation, input, pagination);
334
407
  case "single":
335
- return missingPaginationConfig(
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, requestOptions);
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 array in the page response
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
- const items = getPath(page, itemsPath) as readonly Item[] | undefined;
362
- return Stream.fromIterable(items ?? []);
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
+ });