@distilled.cloud/core 1.0.0-rc.12 → 1.0.0-rc.13

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 (149) hide show
  1. package/lib/api.d.ts +4 -4
  2. package/lib/api.d.ts.map +1 -1
  3. package/lib/api.js +1 -1
  4. package/lib/api.js.map +1 -1
  5. package/lib/category.js +1 -1
  6. package/lib/category.js.map +1 -1
  7. package/lib/codegen/boolean-string-enums.d.ts +8 -8
  8. package/lib/codegen/boolean-string-enums.d.ts.map +1 -1
  9. package/lib/codegen/boolean-string-enums.js +84 -27
  10. package/lib/codegen/boolean-string-enums.js.map +1 -1
  11. package/lib/codegen/boolean-string-enums.test.js +130 -0
  12. package/lib/codegen/boolean-string-enums.test.js.map +1 -1
  13. package/lib/codegen/cli.js +2 -2
  14. package/lib/codegen/cli.js.map +1 -1
  15. package/lib/codegen/generator.d.ts +20 -3
  16. package/lib/codegen/generator.d.ts.map +1 -1
  17. package/lib/codegen/generator.js +36 -9
  18. package/lib/codegen/generator.js.map +1 -1
  19. package/lib/codegen/generator.test.d.ts +2 -0
  20. package/lib/codegen/generator.test.d.ts.map +1 -0
  21. package/lib/codegen/generator.test.js +126 -0
  22. package/lib/codegen/generator.test.js.map +1 -0
  23. package/lib/codegen/graphql-client.d.ts +7 -1
  24. package/lib/codegen/graphql-client.d.ts.map +1 -1
  25. package/lib/codegen/graphql-client.js +262 -125
  26. package/lib/codegen/graphql-client.js.map +1 -1
  27. package/lib/codegen/graphql-client.test.js +104 -236
  28. package/lib/codegen/graphql-client.test.js.map +1 -1
  29. package/lib/codegen/openapi-binary.test.d.ts +2 -0
  30. package/lib/codegen/openapi-binary.test.d.ts.map +1 -0
  31. package/lib/codegen/openapi-binary.test.js +67 -0
  32. package/lib/codegen/openapi-binary.test.js.map +1 -0
  33. package/lib/codegen/openapi.d.ts +7 -1
  34. package/lib/codegen/openapi.d.ts.map +1 -1
  35. package/lib/codegen/openapi.js +15 -4
  36. package/lib/codegen/openapi.js.map +1 -1
  37. package/lib/codegen/patches.d.ts +16 -1
  38. package/lib/codegen/patches.d.ts.map +1 -1
  39. package/lib/codegen/patches.js +70 -4
  40. package/lib/codegen/patches.js.map +1 -1
  41. package/lib/codegen/patches.test.js +48 -2
  42. package/lib/codegen/patches.test.js.map +1 -1
  43. package/lib/codegen/untagged-unions.test.d.ts +2 -0
  44. package/lib/codegen/untagged-unions.test.d.ts.map +1 -0
  45. package/lib/codegen/untagged-unions.test.js +80 -0
  46. package/lib/codegen/untagged-unions.test.js.map +1 -0
  47. package/lib/errors.d.ts +2 -1
  48. package/lib/errors.d.ts.map +1 -1
  49. package/lib/errors.js +5 -2
  50. package/lib/errors.js.map +1 -1
  51. package/lib/fixtures/xml/upstream-validator.json +397 -0
  52. package/lib/graphql.d.ts +296 -205
  53. package/lib/graphql.d.ts.map +1 -1
  54. package/lib/graphql.js +968 -631
  55. package/lib/graphql.js.map +1 -1
  56. package/lib/graphql.test.js +134 -763
  57. package/lib/graphql.test.js.map +1 -1
  58. package/lib/graphql.types.d.ts +10 -0
  59. package/lib/graphql.types.d.ts.map +1 -1
  60. package/lib/graphql.types.js +4 -44
  61. package/lib/graphql.types.js.map +1 -1
  62. package/lib/index.d.ts +27 -0
  63. package/lib/index.d.ts.map +1 -0
  64. package/lib/index.js +27 -0
  65. package/lib/index.js.map +1 -0
  66. package/lib/pagination.d.ts +2 -1
  67. package/lib/pagination.d.ts.map +1 -1
  68. package/lib/pagination.js +24 -16
  69. package/lib/pagination.js.map +1 -1
  70. package/lib/pagination.test.d.ts +2 -0
  71. package/lib/pagination.test.d.ts.map +1 -0
  72. package/lib/pagination.test.js +134 -0
  73. package/lib/pagination.test.js.map +1 -0
  74. package/lib/protocol-http.d.ts +12 -17
  75. package/lib/protocol-http.d.ts.map +1 -1
  76. package/lib/protocol-http.js +100 -45
  77. package/lib/protocol-http.js.map +1 -1
  78. package/lib/protocol-http.test.js +130 -0
  79. package/lib/protocol-http.test.js.map +1 -1
  80. package/lib/protocol-rest.d.ts +18 -1
  81. package/lib/protocol-rest.d.ts.map +1 -1
  82. package/lib/protocol-rest.js +24 -4
  83. package/lib/protocol-rest.js.map +1 -1
  84. package/lib/query.d.ts +29 -0
  85. package/lib/query.d.ts.map +1 -0
  86. package/lib/query.js +27 -0
  87. package/lib/query.js.map +1 -0
  88. package/lib/response-validation.d.ts +67 -0
  89. package/lib/response-validation.d.ts.map +1 -0
  90. package/lib/response-validation.js +76 -0
  91. package/lib/response-validation.js.map +1 -0
  92. package/lib/response-validation.test.d.ts +2 -0
  93. package/lib/response-validation.test.d.ts.map +1 -0
  94. package/lib/response-validation.test.js +67 -0
  95. package/lib/response-validation.test.js.map +1 -0
  96. package/lib/testing.d.ts +39 -0
  97. package/lib/testing.d.ts.map +1 -0
  98. package/lib/testing.js +41 -0
  99. package/lib/testing.js.map +1 -0
  100. package/lib/trait.d.ts +29 -9
  101. package/lib/trait.d.ts.map +1 -1
  102. package/lib/trait.js +8 -0
  103. package/lib/trait.js.map +1 -1
  104. package/lib/xml.d.ts +44 -0
  105. package/lib/xml.d.ts.map +1 -0
  106. package/lib/xml.js +315 -0
  107. package/lib/xml.js.map +1 -0
  108. package/lib/xml.test.d.ts +2 -0
  109. package/lib/xml.test.d.ts.map +1 -0
  110. package/lib/xml.test.js +211 -0
  111. package/lib/xml.test.js.map +1 -0
  112. package/package.json +4 -4
  113. package/src/api.ts +4 -4
  114. package/src/category.ts +1 -1
  115. package/src/codegen/boolean-string-enums.test.ts +147 -0
  116. package/src/codegen/boolean-string-enums.ts +84 -27
  117. package/src/codegen/cli.ts +2 -2
  118. package/src/codegen/generator.test.ts +151 -0
  119. package/src/codegen/generator.ts +70 -12
  120. package/src/codegen/graphql-client.test.ts +116 -283
  121. package/src/codegen/graphql-client.ts +326 -169
  122. package/src/codegen/openapi-binary.test.ts +98 -0
  123. package/src/codegen/openapi.ts +26 -4
  124. package/src/codegen/patches.test.ts +66 -1
  125. package/src/codegen/patches.ts +84 -4
  126. package/src/codegen/untagged-unions.test.ts +89 -0
  127. package/src/errors.ts +5 -2
  128. package/src/fixtures/xml/upstream-validator.json +397 -0
  129. package/src/graphql.test.ts +174 -948
  130. package/src/graphql.ts +1592 -1167
  131. package/src/graphql.types.ts +92 -163
  132. package/src/index.ts +26 -0
  133. package/src/pagination.test.ts +191 -0
  134. package/src/pagination.ts +35 -25
  135. package/src/protocol-http.test.ts +163 -0
  136. package/src/protocol-http.ts +122 -46
  137. package/src/protocol-rest.ts +62 -13
  138. package/src/query.ts +39 -0
  139. package/src/response-validation.test.ts +95 -0
  140. package/src/response-validation.ts +116 -0
  141. package/src/testing.ts +78 -0
  142. package/src/trait.ts +39 -8
  143. package/src/xml.test.ts +269 -0
  144. package/src/xml.ts +339 -0
  145. package/lib/graphql.fixture.d.ts +0 -249
  146. package/lib/graphql.fixture.d.ts.map +0 -1
  147. package/lib/graphql.fixture.js +0 -240
  148. package/lib/graphql.fixture.js.map +0 -1
  149. package/src/graphql.fixture.ts +0 -371
@@ -1,185 +1,114 @@
1
- /** Compile-only contract checks. The coordinator runs these with noCheck=false. */
2
- import type * as G from "./graphql.ts";
3
- import type * as Stream from "effect/Stream";
1
+ /** Compile-only: Query.fn unwraps Query/Effect fields on the returned plan. */
2
+ import type { PlanError, Query, QueryError, UnwrapPlan } from "./query.ts";
3
+ import { mapQuery } from "./graphql.ts";
4
4
  import type {
5
- FixtureSchema,
6
- ProjectNotFound,
7
- ServicesUnavailable,
8
- Unauthorized,
9
- PageInfoUnavailable,
10
- } from "./graphql.fixture.ts";
11
-
12
- type Assert<T extends true> = T;
13
- type Assignable<A, B> = [A] extends [B] ? true : false;
14
- type Equal<A, B> = Assignable<A, B> extends true ? Assignable<B, A> : false;
5
+ itemsQuery,
6
+ GraphQLFailure,
7
+ GraphQLTransportError,
8
+ UnknownGraphQLError,
9
+ } from "./graphql.ts";
10
+ import type * as Effect from "effect/Effect";
11
+ import type * as Stream from "effect/Stream";
15
12
 
16
- type IdentitySelection = {
17
- project: { where: { id: "p1" }; select: { id: true } };
18
- };
19
- type DetailedSelection = {
20
- project: {
21
- where: { id: "p1" };
22
- select: {
23
- id: true;
24
- services: { select: { edges: { node: { id: true } } } };
25
- };
26
- };
27
- };
28
- type AliasSelection = {
29
- __alias: {
30
- production: { project: { where: { id: "p1" }; select: { id: true } } };
31
- };
13
+ type Plan = {
14
+ readonly email: Query<string>;
15
+ readonly projects: Query<ReadonlyArray<{ name: string; stars: number }>>;
16
+ readonly flag: Effect.Effect<boolean>;
32
17
  };
33
- type SearchSelection = {
34
- search: {
35
- where: { text: "prod" };
36
- select: {
37
- __typename: true;
38
- __on: { Project: { name: true }; Service: { status: true } };
39
- };
40
- };
41
- };
42
-
43
- type Identity = G.Result<FixtureSchema, "Query!", IdentitySelection>;
44
- type _Identity = Assert<Equal<Identity, { project: { id: string } | null }>>;
45
- type _UnselectedFieldsAbsent = Assert<
46
- Equal<keyof NonNullable<Identity["project"]>, "id">
47
- >;
48
18
 
49
- type Alias = G.Result<FixtureSchema, "Query!", AliasSelection>;
50
- type _Alias = Assert<Equal<Alias, { production: { id: string } | null }>>;
19
+ type Unwrapped = UnwrapPlan<Plan>;
20
+ type Assert<T extends true> = T;
21
+ type Equal<Left, Right> = [Left] extends [Right]
22
+ ? [Right] extends [Left]
23
+ ? true
24
+ : false
25
+ : false;
51
26
 
52
- type Search = G.Result<FixtureSchema, "Query!", SearchSelection>;
53
- type _Union = Assert<
54
- Equal<
55
- Search["search"][number],
56
- | { __typename: "Project"; name: string }
57
- | { __typename: "Service"; status: "ACTIVE" | "ARCHIVED" }
58
- >
27
+ type _Email = Assert<Equal<Unwrapped["email"], string>>;
28
+ type _Projects = Assert<
29
+ Equal<Unwrapped["projects"], ReadonlyArray<{ name: string; stars: number }>>
59
30
  >;
31
+ type _Flag = Assert<Equal<Unwrapped["flag"], boolean>>;
60
32
 
61
- type IdentityErrors = G.Errors<FixtureSchema, "Query", IdentitySelection>;
62
- type DetailedErrors = G.Errors<FixtureSchema, "Query", DetailedSelection>;
63
- type _RootErrorIncluded = Assert<Assignable<ProjectNotFound, IdentityErrors>>;
64
- type _GlobalErrorIncluded = Assert<Assignable<Unauthorized, IdentityErrors>>;
65
- type _NestedErrorIncluded = Assert<
66
- Assignable<ServicesUnavailable, DetailedErrors>
67
- >;
68
- type _UnselectedErrorExcluded = Assert<
69
- Equal<Extract<IdentityErrors, { _tag: "ServicesUnavailable" }>, never>
33
+ // ── Errors ──────────────────────────────────────────────────────────────────
34
+ declare class NotFound {
35
+ readonly _tag: "NotFound";
36
+ readonly message: string;
37
+ }
38
+ declare class RateLimited {
39
+ readonly _tag: "RateLimited";
40
+ readonly message: string;
41
+ }
42
+ declare const project: Query<
43
+ { name: string; services: ReadonlyArray<{ name: string }> },
44
+ NotFound | RateLimited
70
45
  >;
46
+ declare const me: Query<{ email: string }, RateLimited>;
47
+ declare const lookup: Effect.Effect<number, "lookup-failed">;
48
+
49
+ type ErrorPlan = {
50
+ readonly name: typeof project.name;
51
+ readonly services: Query<ReadonlyArray<string>, NotFound>;
52
+ readonly email: typeof me.email;
53
+ readonly lookup: typeof lookup;
54
+ };
71
55
 
72
- type ConditionalRoot = G.Result<
73
- FixtureSchema,
74
- "Query!",
75
- {
76
- ping: { $include: boolean; select: true };
77
- }
56
+ type _FieldsKeepRootErrors = Assert<
57
+ Equal<(typeof project.name)["errorType"], NotFound | RateLimited>
78
58
  >;
79
- type _ConditionalRoot = Assert<Equal<ConditionalRoot, { ping?: boolean }>>;
80
- type ConditionalNested = G.Result<
81
- FixtureSchema,
82
- "Query!",
83
- {
84
- project: {
85
- where: { id: string };
86
- select: {
87
- id: true;
88
- name: { $skip: boolean; select: true };
89
- };
90
- };
91
- }
59
+ type _PlanError = Assert<
60
+ Equal<PlanError<ErrorPlan>, NotFound | RateLimited | "lookup-failed">
92
61
  >;
93
- type _ConditionalNested = Assert<
62
+ type _NoErrors = Assert<Equal<PlanError<{ literal: 1 }>, never>>;
63
+ type _QueryError = Assert<
94
64
  Equal<
95
- ConditionalNested,
96
- {
97
- project: { id: string; name?: string } | null;
98
- }
65
+ Exclude<
66
+ QueryError<NotFound>,
67
+ | GraphQLFailure<NotFound | UnknownGraphQLError>
68
+ | UnknownGraphQLError
69
+ | GraphQLTransportError
70
+ >,
71
+ NotFound
99
72
  >
100
73
  >;
101
- type ConditionalAlias = G.Result<
102
- FixtureSchema,
103
- "Query!",
104
- {
105
- __alias: { healthy: { ping: { $include: boolean; select: true } } };
106
- }
107
- >;
108
- type _ConditionalAlias = Assert<Equal<ConditionalAlias, { healthy?: boolean }>>;
109
74
 
110
- type MergedInterface = G.Result<
111
- FixtureSchema,
112
- "Node!",
113
- {
114
- __typename: true;
115
- parent: { id: true };
116
- __on: { Project: { parent: { name: true } } };
117
- }
118
- >;
119
- type _MergedInterface = Assert<
75
+ // ── Pagination ──────────────────────────────────────────────────────────────
76
+ declare const services: Query<ReadonlyArray<{ name: string }>, NotFound>;
77
+ type PagedItems = ReturnType<typeof itemsQuery<{ name: string }, NotFound>>;
78
+ type _ItemsValue = Assert<Equal<Stream.Success<PagedItems>, { name: string }>>;
79
+ type _ItemsError = Assert<
120
80
  Equal<
121
- Extract<MergedInterface, { __typename: "Project" }>["parent"],
122
- { id: string; name: string } | null
81
+ Extract<
82
+ Stream.Error<PagedItems>,
83
+ { _tag: "NotFound" | "GraphQLPaginationError" }
84
+ >["_tag"],
85
+ "NotFound" | "GraphQLPaginationError"
123
86
  >
124
87
  >;
88
+ export type _Services = typeof services;
125
89
 
126
- // These assignments exercise user-facing argument and nested selection validation.
127
- const validSelection: G.Selection<FixtureSchema, "Query"> = {
128
- project: {
129
- where: { id: "p1" },
130
- select: {
131
- services: {
132
- where: { first: 10 },
133
- select: { edges: { node: { id: true } } },
134
- },
135
- },
136
- },
137
- };
138
- const missingArgument: G.Selection<FixtureSchema, "Query"> = {
139
- // @ts-expect-error Required GraphQL field arguments cannot be omitted.
140
- project: { select: { id: true } },
141
- };
142
- const wrongArgument: G.Selection<FixtureSchema, "Query"> = {
143
- // @ts-expect-error GraphQL argument types follow the schema.
144
- project: { where: { id: 42 }, select: { id: true } },
145
- };
146
- const unknownField: G.Selection<FixtureSchema, "Query"> = {
147
- // @ts-expect-error Unknown nested fields cannot be selected.
148
- project: { where: { id: "p1" }, select: { invented: true } },
149
- };
150
- // @ts-expect-error Fields returning objects require a projection.
151
- const noProjection: G.Selection<FixtureSchema, "Query"> = { project: true };
152
-
153
- void [
154
- validSelection,
155
- missingArgument,
156
- wrongArgument,
157
- unknownField,
158
- noProjection,
159
- ];
160
-
161
- // Named operations retain projection and pagination constraints during inference.
162
- declare const inferredClient: ReturnType<
163
- typeof G.makeClient<FixtureSchema, never>
90
+ // ── Nullability ─────────────────────────────────────────────────────────────
91
+ declare const nullable: Query<{
92
+ owner: { name: string; tags: ReadonlyArray<string> | null } | null;
93
+ }>;
94
+ type _NullParent = Assert<
95
+ Equal<UnwrapPlan<typeof nullable.owner.name>, string | null>
164
96
  >;
165
97
 
166
- // @ts-expect-error An object-returning operation always requires a projection.
167
- inferredClient.operation("query", "project")({ id: "p1" });
168
- // @ts-expect-error Non-connection object queries do not expose pages.
169
- inferredClient.operation("query", "project").pages;
170
- // @ts-expect-error Scalar queries do not expose connection pagination.
171
- inferredClient.operation("query", "ping").items;
172
- // @ts-expect-error Mutations cannot be paginated.
173
- inferredClient.operation("mutation", "editProject").pages;
174
-
175
- const selectivePages = inferredClient
176
- .operation("query", "services")
177
- .pages({}, { edges: { node: { id: true } } });
178
- type PageErrors = Stream.Error<typeof selectivePages>;
179
- type PageIssues =
180
- Extract<PageErrors, G.GraphQLFailure> extends G.GraphQLFailure<infer E>
181
- ? E
182
- : never;
183
- type _HiddenPaginationErrorIncluded = Assert<
184
- Assignable<PageInfoUnavailable, PageIssues>
98
+ // ── Query.map on objects ────────────────────────────────────────────────────
99
+ declare const service: Query<{
100
+ name: string;
101
+ stars: number;
102
+ latestDeployment: { id: string; status: string } | null;
103
+ tags: ReadonlyArray<{ label: string }>;
104
+ }>;
105
+ const deployment = service.latestDeployment.pipe(
106
+ mapQuery((d) => ({ id: d.id, status: d.status })),
107
+ );
108
+ type _ObjectMap = Assert<
109
+ Equal<UnwrapPlan<typeof deployment>, { id: string; status: string } | null>
185
110
  >;
111
+ const popular = service.stars.pipe(mapQuery((stars) => stars > 5));
112
+ type _ScalarMap = Assert<Equal<UnwrapPlan<typeof popular>, boolean>>;
113
+ const labels = service.tags.pipe(mapQuery((tag) => tag.label));
114
+ type _ListMap = Assert<Equal<UnwrapPlan<typeof labels>, ReadonlyArray<string>>>;
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `@distilled.cloud/core` — the runtime shared by every distilled SDK.
3
+ *
4
+ * ```ts
5
+ * import { ResponseValidation, Retry } from "@distilled.cloud/core";
6
+ *
7
+ * program.pipe(Effect.provide(ResponseValidation.strict));
8
+ * ```
9
+ *
10
+ * Each namespace is also importable on its own subpath
11
+ * (`@distilled.cloud/core/response-validation`, …); generated SDKs use the
12
+ * subpaths. Code generation (`codegen/*`) and test helpers (`testing`) stay
13
+ * subpath-only.
14
+ */
15
+ export * as API from "./api.ts";
16
+ export * as Category from "./category.ts";
17
+ export * as Errors from "./errors.ts";
18
+ export * as GraphQL from "./graphql.ts";
19
+ export * as Pagination from "./pagination.ts";
20
+ export * as ProtocolHttp from "./protocol-http.ts";
21
+ export * as ProtocolRest from "./protocol-rest.ts";
22
+ export { Query } from "./query.ts";
23
+ export * as ResponseValidation from "./response-validation.ts";
24
+ export * as Retry from "./retry.ts";
25
+ export * as RetryAfter from "./retry-after.ts";
26
+ export * as Trait from "./trait.ts";
@@ -0,0 +1,191 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Stream from "effect/Stream";
4
+ import {
5
+ extractItems,
6
+ paginateCursor,
7
+ type PaginatedTrait,
8
+ } from "./pagination.ts";
9
+
10
+ const pagination: PaginatedTrait = {
11
+ mode: "cursor",
12
+ inputToken: "cursor",
13
+ outputToken: "pagination.cursor",
14
+ items: "items",
15
+ pageSize: "limit",
16
+ };
17
+
18
+ type Page = {
19
+ items?: number[];
20
+ pagination?: { cursor?: string | null };
21
+ };
22
+
23
+ type Scenario = {
24
+ name: string;
25
+ start?: string;
26
+ pages: Page[];
27
+ cursors: Array<string | undefined>;
28
+ };
29
+
30
+ const scenarios: Scenario[] = [
31
+ {
32
+ name: "advances normally until the cursor is omitted",
33
+ pages: [
34
+ { items: [1], pagination: { cursor: "a" } },
35
+ { items: [2], pagination: { cursor: "b" } },
36
+ { items: [3] },
37
+ ],
38
+ cursors: [undefined, "a", "b"],
39
+ },
40
+ ...([null, ""] as const).map((cursor) => ({
41
+ name: `terminates on ${JSON.stringify(cursor)}`,
42
+ pages: [
43
+ { items: [1], pagination: { cursor: "a" } },
44
+ { items: [2], pagination: { cursor } },
45
+ ],
46
+ cursors: [undefined, "a"],
47
+ })),
48
+ {
49
+ name: "terminates on an empty page echoing the requested cursor",
50
+ pages: [
51
+ { items: [1], pagination: { cursor: "a" } },
52
+ { items: [], pagination: { cursor: "a" } },
53
+ ],
54
+ cursors: [undefined, "a"],
55
+ },
56
+ {
57
+ name: "emits a nonempty page before stopping on a repeated cursor",
58
+ pages: [
59
+ { items: [1], pagination: { cursor: "a" } },
60
+ { items: [2], pagination: { cursor: "a" } },
61
+ ],
62
+ cursors: [undefined, "a"],
63
+ },
64
+ {
65
+ name: "terminates when the first response echoes the initial cursor",
66
+ start: "initial",
67
+ pages: [{ items: [1], pagination: { cursor: "initial" } }],
68
+ cursors: ["initial"],
69
+ },
70
+ {
71
+ name: "terminates a multi-cursor cycle",
72
+ pages: [
73
+ { items: [1], pagination: { cursor: "a" } },
74
+ { items: [2], pagination: { cursor: "b" } },
75
+ { items: [3], pagination: { cursor: "a" } },
76
+ ],
77
+ cursors: [undefined, "a", "b"],
78
+ },
79
+ {
80
+ name: "terminates a cycle returning to the initial cursor",
81
+ start: "initial",
82
+ pages: [
83
+ { items: [1], pagination: { cursor: "a" } },
84
+ { items: [2], pagination: { cursor: "initial" } },
85
+ ],
86
+ cursors: ["initial", "a"],
87
+ },
88
+ {
89
+ name: "preserves empty and sparse pages whose cursors advance",
90
+ pages: [
91
+ { items: [], pagination: { cursor: "a" } },
92
+ { pagination: { cursor: "b" } },
93
+ { items: [1], pagination: { cursor: "c" } },
94
+ { items: [], pagination: { cursor: "d" } },
95
+ { items: [2] },
96
+ ],
97
+ cursors: [undefined, "a", "b", "c", "d"],
98
+ },
99
+ ];
100
+
101
+ const fixture = ({ pages, start }: Scenario) => {
102
+ const requests: Array<{ cursor?: string; limit: number }> = [];
103
+ const input = { ...(start === undefined ? {} : { cursor: start }), limit: 1 };
104
+ const stream = paginateCursor(
105
+ (request) =>
106
+ Effect.sync(() => {
107
+ const page = pages[requests.length];
108
+ requests.push(request);
109
+ if (!page)
110
+ throw new Error("Paginator made an unexpected extra request");
111
+ return page;
112
+ }),
113
+ input,
114
+ pagination,
115
+ );
116
+ return { stream, requests, input };
117
+ };
118
+
119
+ const collect = <A>(stream: Stream.Stream<A>) =>
120
+ Stream.runCollect(stream).pipe(Effect.timeout("1 second"));
121
+
122
+ describe("cursor pagination", () => {
123
+ test.each(scenarios)("$name", (scenario) =>
124
+ Effect.runPromise(
125
+ Effect.gen(function* () {
126
+ const { stream, requests, input } = fixture(scenario);
127
+ const pages = yield* collect(stream);
128
+ expect(Array.from(pages)).toEqual(scenario.pages);
129
+ expect(requests.map((request) => request.cursor)).toEqual(
130
+ scenario.cursors,
131
+ );
132
+ expect(requests.every((request) => request.limit === 1)).toBe(true);
133
+ expect(input.cursor).toBe(scenario.start);
134
+ }),
135
+ ),
136
+ );
137
+
138
+ test.each(scenarios)("item extraction: $name", (scenario) =>
139
+ Effect.runPromise(
140
+ Effect.gen(function* () {
141
+ const { stream, requests } = fixture(scenario);
142
+ const items = yield* collect(
143
+ extractItems<Page, number, never, never>(stream, "items"),
144
+ );
145
+ expect(Array.from(items)).toEqual(
146
+ scenario.pages.flatMap((page) => page.items ?? []),
147
+ );
148
+ expect(requests.map((request) => request.cursor)).toEqual(
149
+ scenario.cursors,
150
+ );
151
+ }),
152
+ ),
153
+ );
154
+
155
+ test("cursor history is isolated across repeated and concurrent stream runs", () =>
156
+ Effect.runPromise(
157
+ Effect.gen(function* () {
158
+ let calls = 0;
159
+ const pages: Page[] = [
160
+ { items: [1], pagination: { cursor: "a" } },
161
+ { items: [2], pagination: { cursor: "b" } },
162
+ { items: [], pagination: { cursor: "b" } },
163
+ ];
164
+ const stream = paginateCursor(
165
+ (input: { cursor?: string }) =>
166
+ Effect.sync(() => {
167
+ if (++calls > 12)
168
+ throw new Error("Paginator exceeded the request bound");
169
+ return pages[
170
+ input.cursor === "a" ? 1 : input.cursor === "b" ? 2 : 0
171
+ ]!;
172
+ }),
173
+ {},
174
+ pagination,
175
+ );
176
+ expect(Array.from(yield* collect(stream))).toEqual(pages);
177
+ expect(Array.from(yield* collect(stream))).toEqual(pages);
178
+ const concurrent = yield* Effect.all(
179
+ [collect(stream), collect(stream)],
180
+ {
181
+ concurrency: "unbounded",
182
+ },
183
+ );
184
+ expect(concurrent.map((result) => Array.from(result))).toEqual([
185
+ pages,
186
+ pages,
187
+ ]);
188
+ expect(calls).toBe(12);
189
+ }),
190
+ ));
191
+ });
package/src/pagination.ts CHANGED
@@ -199,7 +199,8 @@ export const paginatePageNumber = <
199
199
 
200
200
  /**
201
201
  * Stream of pages using cursor-based pagination — follow `outputToken`
202
- * cursors until one comes back absent.
202
+ * cursors until one comes back absent or repeats a previously requested cursor.
203
+ * Empty pages still advance when they return a new cursor.
203
204
  */
204
205
  export const paginateCursor = <
205
206
  Input extends Record<string, unknown>,
@@ -224,30 +225,39 @@ export const paginateCursor = <
224
225
  ? (input[inputToken] as string)
225
226
  : undefined;
226
227
 
227
- return Stream.unfold({ cursor: startCursor, done: false } as State, (state) =>
228
- Effect.gen(function* () {
229
- if (state.done) return undefined;
230
-
231
- const requestPayload = {
232
- ...input,
233
- ...(state.cursor ? { [inputToken]: state.cursor } : {}),
234
- } as Input;
235
-
236
- const response = yield* operation(requestPayload);
237
-
238
- const nextCursor = getPath(response, outputToken) as
239
- | string
240
- | null
241
- | undefined;
242
-
243
- const nextState: State = {
244
- cursor: nextCursor ?? undefined,
245
- done: isTerminalToken(nextCursor),
246
- };
247
-
248
- return [response, nextState] as const;
249
- }),
250
- );
228
+ return Stream.suspend(() => {
229
+ // Cursor history belongs to this traversal, not to the reusable stream.
230
+ const requestedCursors = new Set<string>();
231
+ return Stream.unfold(
232
+ { cursor: startCursor, done: false } as State,
233
+ (state) =>
234
+ Effect.gen(function* () {
235
+ if (state.done) return undefined;
236
+ if (state.cursor !== undefined) requestedCursors.add(state.cursor);
237
+
238
+ const requestPayload = {
239
+ ...input,
240
+ ...(state.cursor ? { [inputToken]: state.cursor } : {}),
241
+ } as Input;
242
+
243
+ const response = yield* operation(requestPayload);
244
+
245
+ const nextCursor = getPath(response, outputToken) as
246
+ | string
247
+ | null
248
+ | undefined;
249
+
250
+ const nextState: State = {
251
+ cursor: nextCursor ?? undefined,
252
+ done:
253
+ isTerminalToken(nextCursor) ||
254
+ (nextCursor != null && requestedCursors.has(nextCursor)),
255
+ };
256
+
257
+ return [response, nextState] as const;
258
+ }),
259
+ );
260
+ });
251
261
  };
252
262
 
253
263
  // ============================================================================