@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.
- package/lib/api.d.ts +4 -4
- package/lib/api.d.ts.map +1 -1
- package/lib/api.js +1 -1
- package/lib/api.js.map +1 -1
- package/lib/category.js +1 -1
- package/lib/category.js.map +1 -1
- package/lib/codegen/boolean-string-enums.d.ts +8 -8
- package/lib/codegen/boolean-string-enums.d.ts.map +1 -1
- package/lib/codegen/boolean-string-enums.js +84 -27
- package/lib/codegen/boolean-string-enums.js.map +1 -1
- package/lib/codegen/boolean-string-enums.test.js +130 -0
- package/lib/codegen/boolean-string-enums.test.js.map +1 -1
- package/lib/codegen/cli.js +2 -2
- package/lib/codegen/cli.js.map +1 -1
- package/lib/codegen/generator.d.ts +20 -3
- package/lib/codegen/generator.d.ts.map +1 -1
- package/lib/codegen/generator.js +36 -9
- package/lib/codegen/generator.js.map +1 -1
- package/lib/codegen/generator.test.d.ts +2 -0
- package/lib/codegen/generator.test.d.ts.map +1 -0
- package/lib/codegen/generator.test.js +126 -0
- package/lib/codegen/generator.test.js.map +1 -0
- package/lib/codegen/graphql-client.d.ts +7 -1
- package/lib/codegen/graphql-client.d.ts.map +1 -1
- package/lib/codegen/graphql-client.js +262 -125
- package/lib/codegen/graphql-client.js.map +1 -1
- package/lib/codegen/graphql-client.test.js +104 -236
- package/lib/codegen/graphql-client.test.js.map +1 -1
- package/lib/codegen/openapi-binary.test.d.ts +2 -0
- package/lib/codegen/openapi-binary.test.d.ts.map +1 -0
- package/lib/codegen/openapi-binary.test.js +67 -0
- package/lib/codegen/openapi-binary.test.js.map +1 -0
- package/lib/codegen/openapi.d.ts +7 -1
- package/lib/codegen/openapi.d.ts.map +1 -1
- package/lib/codegen/openapi.js +15 -4
- package/lib/codegen/openapi.js.map +1 -1
- package/lib/codegen/patches.d.ts +16 -1
- package/lib/codegen/patches.d.ts.map +1 -1
- package/lib/codegen/patches.js +70 -4
- package/lib/codegen/patches.js.map +1 -1
- package/lib/codegen/patches.test.js +48 -2
- package/lib/codegen/patches.test.js.map +1 -1
- package/lib/codegen/untagged-unions.test.d.ts +2 -0
- package/lib/codegen/untagged-unions.test.d.ts.map +1 -0
- package/lib/codegen/untagged-unions.test.js +80 -0
- package/lib/codegen/untagged-unions.test.js.map +1 -0
- package/lib/errors.d.ts +2 -1
- package/lib/errors.d.ts.map +1 -1
- package/lib/errors.js +5 -2
- package/lib/errors.js.map +1 -1
- package/lib/fixtures/xml/upstream-validator.json +397 -0
- package/lib/graphql.d.ts +296 -205
- package/lib/graphql.d.ts.map +1 -1
- package/lib/graphql.js +968 -631
- package/lib/graphql.js.map +1 -1
- package/lib/graphql.test.js +134 -763
- package/lib/graphql.test.js.map +1 -1
- package/lib/graphql.types.d.ts +10 -0
- package/lib/graphql.types.d.ts.map +1 -1
- package/lib/graphql.types.js +4 -44
- package/lib/graphql.types.js.map +1 -1
- package/lib/index.d.ts +27 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +27 -0
- package/lib/index.js.map +1 -0
- package/lib/pagination.d.ts +2 -1
- package/lib/pagination.d.ts.map +1 -1
- package/lib/pagination.js +24 -16
- package/lib/pagination.js.map +1 -1
- package/lib/pagination.test.d.ts +2 -0
- package/lib/pagination.test.d.ts.map +1 -0
- package/lib/pagination.test.js +134 -0
- package/lib/pagination.test.js.map +1 -0
- package/lib/protocol-http.d.ts +12 -17
- package/lib/protocol-http.d.ts.map +1 -1
- package/lib/protocol-http.js +100 -45
- package/lib/protocol-http.js.map +1 -1
- package/lib/protocol-http.test.js +130 -0
- package/lib/protocol-http.test.js.map +1 -1
- package/lib/protocol-rest.d.ts +18 -1
- package/lib/protocol-rest.d.ts.map +1 -1
- package/lib/protocol-rest.js +24 -4
- package/lib/protocol-rest.js.map +1 -1
- package/lib/query.d.ts +29 -0
- package/lib/query.d.ts.map +1 -0
- package/lib/query.js +27 -0
- package/lib/query.js.map +1 -0
- package/lib/response-validation.d.ts +67 -0
- package/lib/response-validation.d.ts.map +1 -0
- package/lib/response-validation.js +76 -0
- package/lib/response-validation.js.map +1 -0
- package/lib/response-validation.test.d.ts +2 -0
- package/lib/response-validation.test.d.ts.map +1 -0
- package/lib/response-validation.test.js +67 -0
- package/lib/response-validation.test.js.map +1 -0
- package/lib/testing.d.ts +39 -0
- package/lib/testing.d.ts.map +1 -0
- package/lib/testing.js +41 -0
- package/lib/testing.js.map +1 -0
- package/lib/trait.d.ts +29 -9
- package/lib/trait.d.ts.map +1 -1
- package/lib/trait.js +8 -0
- package/lib/trait.js.map +1 -1
- package/lib/xml.d.ts +44 -0
- package/lib/xml.d.ts.map +1 -0
- package/lib/xml.js +315 -0
- package/lib/xml.js.map +1 -0
- package/lib/xml.test.d.ts +2 -0
- package/lib/xml.test.d.ts.map +1 -0
- package/lib/xml.test.js +211 -0
- package/lib/xml.test.js.map +1 -0
- package/package.json +4 -4
- package/src/api.ts +4 -4
- package/src/category.ts +1 -1
- package/src/codegen/boolean-string-enums.test.ts +147 -0
- package/src/codegen/boolean-string-enums.ts +84 -27
- package/src/codegen/cli.ts +2 -2
- package/src/codegen/generator.test.ts +151 -0
- package/src/codegen/generator.ts +70 -12
- package/src/codegen/graphql-client.test.ts +116 -283
- package/src/codegen/graphql-client.ts +326 -169
- package/src/codegen/openapi-binary.test.ts +98 -0
- package/src/codegen/openapi.ts +26 -4
- package/src/codegen/patches.test.ts +66 -1
- package/src/codegen/patches.ts +84 -4
- package/src/codegen/untagged-unions.test.ts +89 -0
- package/src/errors.ts +5 -2
- package/src/fixtures/xml/upstream-validator.json +397 -0
- package/src/graphql.test.ts +174 -948
- package/src/graphql.ts +1592 -1167
- package/src/graphql.types.ts +92 -163
- package/src/index.ts +26 -0
- package/src/pagination.test.ts +191 -0
- package/src/pagination.ts +35 -25
- package/src/protocol-http.test.ts +163 -0
- package/src/protocol-http.ts +122 -46
- package/src/protocol-rest.ts +62 -13
- package/src/query.ts +39 -0
- package/src/response-validation.test.ts +95 -0
- package/src/response-validation.ts +116 -0
- package/src/testing.ts +78 -0
- package/src/trait.ts +39 -8
- package/src/xml.test.ts +269 -0
- package/src/xml.ts +339 -0
- package/lib/graphql.fixture.d.ts +0 -249
- package/lib/graphql.fixture.d.ts.map +0 -1
- package/lib/graphql.fixture.js +0 -240
- package/lib/graphql.fixture.js.map +0 -1
- package/src/graphql.fixture.ts +0 -371
package/src/graphql.types.ts
CHANGED
|
@@ -1,185 +1,114 @@
|
|
|
1
|
-
/** Compile-only
|
|
2
|
-
import type
|
|
3
|
-
import
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
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
|
|
50
|
-
type
|
|
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
|
|
53
|
-
type
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
73
|
-
|
|
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
|
|
80
|
-
|
|
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
|
|
62
|
+
type _NoErrors = Assert<Equal<PlanError<{ literal: 1 }>, never>>;
|
|
63
|
+
type _QueryError = Assert<
|
|
94
64
|
Equal<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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<
|
|
122
|
-
|
|
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
|
-
//
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
// ============================================================================
|