@distilled.cloud/core 1.0.0-rc.1 → 1.0.0-rc.11

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 (148) hide show
  1. package/LICENSE +201 -0
  2. package/lib/api.d.ts.map +1 -1
  3. package/lib/api.js +16 -4
  4. package/lib/api.js.map +1 -1
  5. package/lib/category.d.ts +4 -4
  6. package/lib/category.js +4 -4
  7. package/lib/codegen/boolean-string-enums.d.ts +36 -0
  8. package/lib/codegen/boolean-string-enums.d.ts.map +1 -0
  9. package/lib/codegen/boolean-string-enums.js +94 -0
  10. package/lib/codegen/boolean-string-enums.js.map +1 -0
  11. package/lib/codegen/boolean-string-enums.test.d.ts +2 -0
  12. package/lib/codegen/boolean-string-enums.test.d.ts.map +1 -0
  13. package/lib/codegen/boolean-string-enums.test.js +147 -0
  14. package/lib/codegen/boolean-string-enums.test.js.map +1 -0
  15. package/lib/codegen/cli.d.ts +56 -6
  16. package/lib/codegen/cli.d.ts.map +1 -1
  17. package/lib/codegen/cli.js +59 -75
  18. package/lib/codegen/cli.js.map +1 -1
  19. package/lib/codegen/emit.d.ts +3 -7
  20. package/lib/codegen/emit.d.ts.map +1 -1
  21. package/lib/codegen/emit.js +6 -10
  22. package/lib/codegen/emit.js.map +1 -1
  23. package/lib/codegen/generator.d.ts.map +1 -1
  24. package/lib/codegen/generator.js +142 -20
  25. package/lib/codegen/generator.js.map +1 -1
  26. package/lib/codegen/graphql-client.d.ts +62 -0
  27. package/lib/codegen/graphql-client.d.ts.map +1 -0
  28. package/lib/codegen/graphql-client.js +294 -0
  29. package/lib/codegen/graphql-client.js.map +1 -0
  30. package/lib/codegen/graphql-client.test.d.ts +2 -0
  31. package/lib/codegen/graphql-client.test.d.ts.map +1 -0
  32. package/lib/codegen/graphql-client.test.js +311 -0
  33. package/lib/codegen/graphql-client.test.js.map +1 -0
  34. package/lib/codegen/graphql.d.ts +207 -0
  35. package/lib/codegen/graphql.d.ts.map +1 -0
  36. package/lib/codegen/graphql.js +799 -0
  37. package/lib/codegen/graphql.js.map +1 -0
  38. package/lib/codegen/openapi-cli.d.ts +17 -3
  39. package/lib/codegen/openapi-cli.d.ts.map +1 -1
  40. package/lib/codegen/openapi-cli.js +50 -48
  41. package/lib/codegen/openapi-cli.js.map +1 -1
  42. package/lib/codegen/openapi.d.ts +69 -6
  43. package/lib/codegen/openapi.d.ts.map +1 -1
  44. package/lib/codegen/openapi.js +173 -16
  45. package/lib/codegen/openapi.js.map +1 -1
  46. package/lib/codegen/patches.d.ts +65 -0
  47. package/lib/codegen/patches.d.ts.map +1 -0
  48. package/lib/codegen/patches.js +236 -0
  49. package/lib/codegen/patches.js.map +1 -0
  50. package/lib/codegen/patches.test.d.ts +2 -0
  51. package/lib/codegen/patches.test.d.ts.map +1 -0
  52. package/lib/codegen/patches.test.js +105 -0
  53. package/lib/codegen/patches.test.js.map +1 -0
  54. package/lib/codegen/proto.d.ts +121 -0
  55. package/lib/codegen/proto.d.ts.map +1 -0
  56. package/lib/codegen/proto.js +962 -0
  57. package/lib/codegen/proto.js.map +1 -0
  58. package/lib/codegen/rewrite-operation-ids.d.ts +131 -0
  59. package/lib/codegen/rewrite-operation-ids.d.ts.map +1 -0
  60. package/lib/codegen/rewrite-operation-ids.js +1079 -0
  61. package/lib/codegen/rewrite-operation-ids.js.map +1 -0
  62. package/lib/codegen/rewrite-operation-ids.test.d.ts +2 -0
  63. package/lib/codegen/rewrite-operation-ids.test.d.ts.map +1 -0
  64. package/lib/codegen/rewrite-operation-ids.test.js +533 -0
  65. package/lib/codegen/rewrite-operation-ids.test.js.map +1 -0
  66. package/lib/codegen/spec-path.d.ts +16 -0
  67. package/lib/codegen/spec-path.d.ts.map +1 -0
  68. package/lib/codegen/spec-path.js +101 -0
  69. package/lib/codegen/spec-path.js.map +1 -0
  70. package/lib/errors.d.ts.map +1 -1
  71. package/lib/errors.js +17 -13
  72. package/lib/errors.js.map +1 -1
  73. package/lib/graphql.d.ts +284 -0
  74. package/lib/graphql.d.ts.map +1 -0
  75. package/lib/graphql.fixture.d.ts +249 -0
  76. package/lib/graphql.fixture.d.ts.map +1 -0
  77. package/lib/graphql.fixture.js +240 -0
  78. package/lib/graphql.fixture.js.map +1 -0
  79. package/lib/graphql.js +718 -0
  80. package/lib/graphql.js.map +1 -0
  81. package/lib/graphql.test.d.ts +2 -0
  82. package/lib/graphql.test.d.ts.map +1 -0
  83. package/lib/graphql.test.js +780 -0
  84. package/lib/graphql.test.js.map +1 -0
  85. package/lib/graphql.types.d.ts +2 -0
  86. package/lib/graphql.types.d.ts.map +1 -0
  87. package/lib/graphql.types.js +45 -0
  88. package/lib/graphql.types.js.map +1 -0
  89. package/lib/json-patch.d.ts +18 -11
  90. package/lib/json-patch.d.ts.map +1 -1
  91. package/lib/json-patch.js +63 -25
  92. package/lib/json-patch.js.map +1 -1
  93. package/lib/pagination.d.ts +43 -3
  94. package/lib/pagination.d.ts.map +1 -1
  95. package/lib/pagination.js +91 -5
  96. package/lib/pagination.js.map +1 -1
  97. package/lib/protocol-http.d.ts.map +1 -1
  98. package/lib/protocol-http.js +62 -26
  99. package/lib/protocol-http.js.map +1 -1
  100. package/lib/protocol-http.test.d.ts +2 -0
  101. package/lib/protocol-http.test.d.ts.map +1 -0
  102. package/lib/protocol-http.test.js +88 -0
  103. package/lib/protocol-http.test.js.map +1 -0
  104. package/lib/protocol-rest.d.ts +12 -2
  105. package/lib/protocol-rest.d.ts.map +1 -1
  106. package/lib/protocol-rest.js +17 -3
  107. package/lib/protocol-rest.js.map +1 -1
  108. package/lib/retry.js +1 -1
  109. package/lib/schema.d.ts +1 -1
  110. package/lib/schema.d.ts.map +1 -1
  111. package/lib/schema.js +1 -1
  112. package/lib/schema.js.map +1 -1
  113. package/lib/trait.d.ts +27 -3
  114. package/lib/trait.d.ts.map +1 -1
  115. package/lib/trait.js +17 -1
  116. package/lib/trait.js.map +1 -1
  117. package/package.json +13 -10
  118. package/src/api.ts +18 -4
  119. package/src/category.ts +4 -4
  120. package/src/codegen/boolean-string-enums.test.ts +168 -0
  121. package/src/codegen/boolean-string-enums.ts +106 -0
  122. package/src/codegen/cli.ts +127 -110
  123. package/src/codegen/emit.ts +6 -10
  124. package/src/codegen/generator.ts +151 -21
  125. package/src/codegen/graphql-client.test.ts +386 -0
  126. package/src/codegen/graphql-client.ts +419 -0
  127. package/src/codegen/graphql.ts +1217 -0
  128. package/src/codegen/openapi-cli.ts +75 -59
  129. package/src/codegen/openapi.ts +255 -16
  130. package/src/codegen/patches.test.ts +130 -0
  131. package/src/codegen/patches.ts +291 -0
  132. package/src/codegen/proto.ts +1128 -0
  133. package/src/codegen/rewrite-operation-ids.test.ts +563 -0
  134. package/src/codegen/rewrite-operation-ids.ts +1206 -0
  135. package/src/codegen/spec-path.ts +115 -0
  136. package/src/errors.ts +20 -25
  137. package/src/graphql.fixture.ts +371 -0
  138. package/src/graphql.test.ts +974 -0
  139. package/src/graphql.ts +1321 -0
  140. package/src/graphql.types.ts +185 -0
  141. package/src/json-patch.ts +82 -25
  142. package/src/pagination.ts +134 -7
  143. package/src/protocol-http.test.ts +107 -0
  144. package/src/protocol-http.ts +67 -31
  145. package/src/protocol-rest.ts +28 -4
  146. package/src/retry.ts +1 -1
  147. package/src/schema.ts +2 -2
  148. package/src/trait.ts +39 -3
@@ -0,0 +1,185 @@
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";
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;
15
+
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
+ };
32
+ };
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
+
49
+ type Alias = G.Result<FixtureSchema, "Query!", AliasSelection>;
50
+ type _Alias = Assert<Equal<Alias, { production: { id: string } | null }>>;
51
+
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
+ >
59
+ >;
60
+
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>
70
+ >;
71
+
72
+ type ConditionalRoot = G.Result<
73
+ FixtureSchema,
74
+ "Query!",
75
+ {
76
+ ping: { $include: boolean; select: true };
77
+ }
78
+ >;
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
+ }
92
+ >;
93
+ type _ConditionalNested = Assert<
94
+ Equal<
95
+ ConditionalNested,
96
+ {
97
+ project: { id: string; name?: string } | null;
98
+ }
99
+ >
100
+ >;
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
+
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<
120
+ Equal<
121
+ Extract<MergedInterface, { __typename: "Project" }>["parent"],
122
+ { id: string; name: string } | null
123
+ >
124
+ >;
125
+
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>
164
+ >;
165
+
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>
185
+ >;
package/src/json-patch.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * JSON Patch (RFC 6902) Implementation
3
3
  *
4
- * Provides a unified spec patching system for all SDKs. Patches are applied to
5
- * generated intermediary specs (Smithy models, OpenAPI docs, …) before code
6
- * generation to add typed error shapes, rename operations, mark nullable
7
- * fields, etc. Ported from the distilled repo's `core/json-patch`.
4
+ * Provides a unified spec patching system for all SDKs. Patches apply in
5
+ * `convert` so `.generated-specs` is the patched Smithy model. generate does
6
+ * not patch. Prefer `operationNaming: "verbNoun"` for operationId renames —
7
+ * JSON pointers at `/paths/~1foo/operationId` go stale when upstream prefixes
8
+ * paths.
8
9
  *
9
10
  * Pure functions only — callers load patch files themselves (the generators
10
11
  * use Effect's FileSystem) and hand the parsed operations to `applyPatch`.
@@ -45,19 +46,44 @@ export function parseJsonPointer(pointer: string): string[] {
45
46
  }
46
47
 
47
48
  /** Get a value at a JSON Pointer path. */
49
+ /**
50
+ * The target location of a patch operation is absent from the document —
51
+ * spec drift (renamed/removed upstream), not a malformed patch.
52
+ */
53
+ export class StaleTargetError extends Error {
54
+ override readonly name = "StaleTargetError";
55
+ }
56
+
48
57
  export function getValueAtPath(obj: unknown, pointer: string): unknown {
49
58
  const segments = parseJsonPointer(pointer);
50
59
  let current: unknown = obj;
51
60
 
52
61
  for (const segment of segments) {
53
- if (current === null || typeof current !== "object") {
54
- throw new Error(`Cannot traverse path ${pointer}: not an object`);
62
+ if (
63
+ current === null ||
64
+ current === undefined ||
65
+ typeof current !== "object"
66
+ ) {
67
+ throw new StaleTargetError(
68
+ `JSON pointer ${pointer} missing (at '${segment}'): not an object`,
69
+ );
55
70
  }
56
71
  if (Array.isArray(current)) {
57
72
  const index = segment === "-" ? current.length : parseInt(segment, 10);
73
+ if (index < 0 || index >= current.length) {
74
+ throw new StaleTargetError(
75
+ `JSON pointer ${pointer} missing index '${segment}'`,
76
+ );
77
+ }
58
78
  current = current[index];
59
79
  } else {
60
- current = (current as Record<string, unknown>)[segment];
80
+ const record = current as Record<string, unknown>;
81
+ if (!Object.prototype.hasOwnProperty.call(record, segment)) {
82
+ throw new StaleTargetError(
83
+ `JSON pointer ${pointer} missing key '${segment}'`,
84
+ );
85
+ }
86
+ current = record[segment];
61
87
  }
62
88
  }
63
89
 
@@ -80,7 +106,9 @@ export function setValueAtPath(
80
106
  for (let i = 0; i < segments.length - 1; i++) {
81
107
  const segment = segments[i]!;
82
108
  if (current === null || typeof current !== "object") {
83
- throw new Error(`Cannot traverse path ${pointer}: not an object`);
109
+ throw new StaleTargetError(
110
+ `Cannot traverse path ${pointer}: not an object`,
111
+ );
84
112
  }
85
113
  if (Array.isArray(current)) {
86
114
  current = current[parseInt(segment, 10)];
@@ -91,7 +119,7 @@ export function setValueAtPath(
91
119
 
92
120
  const lastSegment = segments[segments.length - 1]!;
93
121
  if (current === null || typeof current !== "object") {
94
- throw new Error(
122
+ throw new StaleTargetError(
95
123
  `Cannot set value at path ${pointer}: parent is not an object`,
96
124
  );
97
125
  }
@@ -119,7 +147,9 @@ export function removeValueAtPath(obj: unknown, pointer: string): void {
119
147
  for (let i = 0; i < segments.length - 1; i++) {
120
148
  const segment = segments[i]!;
121
149
  if (current === null || typeof current !== "object") {
122
- throw new Error(`Cannot traverse path ${pointer}: not an object`);
150
+ throw new StaleTargetError(
151
+ `Cannot traverse path ${pointer}: not an object`,
152
+ );
123
153
  }
124
154
  if (Array.isArray(current)) {
125
155
  current = current[parseInt(segment, 10)];
@@ -130,15 +160,28 @@ export function removeValueAtPath(obj: unknown, pointer: string): void {
130
160
 
131
161
  const lastSegment = segments[segments.length - 1]!;
132
162
  if (current === null || typeof current !== "object") {
133
- throw new Error(
163
+ throw new StaleTargetError(
134
164
  `Cannot remove at path ${pointer}: parent is not an object`,
135
165
  );
136
166
  }
137
167
 
168
+ // RFC 6902 §4.2: the target location MUST exist.
138
169
  if (Array.isArray(current)) {
139
- current.splice(parseInt(lastSegment, 10), 1);
170
+ const index = parseInt(lastSegment, 10);
171
+ if (Number.isNaN(index) || index < 0 || index >= current.length) {
172
+ throw new StaleTargetError(
173
+ `JSON pointer ${pointer} missing index '${lastSegment}'`,
174
+ );
175
+ }
176
+ current.splice(index, 1);
140
177
  } else {
141
- delete (current as Record<string, unknown>)[lastSegment];
178
+ const record = current as Record<string, unknown>;
179
+ if (!Object.prototype.hasOwnProperty.call(record, lastSegment)) {
180
+ throw new StaleTargetError(
181
+ `JSON pointer ${pointer} missing key '${lastSegment}'`,
182
+ );
183
+ }
184
+ delete record[lastSegment];
142
185
  }
143
186
  }
144
187
 
@@ -158,16 +201,21 @@ export function applyOperation(
158
201
  case "remove":
159
202
  removeValueAtPath(obj, operation.path);
160
203
  break;
161
- case "replace":
162
- // For replace, the path must exist
163
- getValueAtPath(obj, operation.path); // throws if doesn't exist
204
+ case "replace": {
205
+ const existing = getValueAtPath(obj, operation.path);
206
+ if (existing === undefined) {
207
+ throw new StaleTargetError(
208
+ `JSON pointer ${operation.path} does not exist`,
209
+ );
210
+ }
164
211
  setValueAtPath(obj, operation.path, operation.value);
165
212
  break;
213
+ }
166
214
  case "move": {
167
215
  if (!operation.from) throw new Error("move operation requires 'from'");
168
216
  const moveValue = getValueAtPath(obj, operation.from);
169
217
  if (moveValue === undefined) {
170
- throw new Error(
218
+ throw new StaleTargetError(
171
219
  `Cannot move from path ${operation.from}: not an object`,
172
220
  );
173
221
  }
@@ -207,16 +255,25 @@ export function applyPatch(obj: unknown, patch: JsonPatch): void {
207
255
  }
208
256
 
209
257
  /**
210
- * Whether a per-operation failure is caused by the target location being
211
- * absent from the spec (i.e. spec drift — the operation/shape the patch
212
- * targets was renamed or removed upstream) as opposed to a malformed patch.
213
- * Stale targets should be skipped with a warning rather than aborting the
214
- * whole generate; a patch that only adds an error to an operation that no
215
- * longer exists is harmless to drop.
258
+ * Whether a per-operation failure is a {@link StaleTargetError} (the
259
+ * operation/shape the patch targets was renamed or removed upstream) as
260
+ * opposed to a malformed patch. Accepts the thrown value or its message.
261
+ * Convert fails the run on these by default (`onStalePatch: "fail"`);
262
+ * `"warn"` restores skip-and-continue.
216
263
  */
217
- export function isStaleTargetError(message: string): boolean {
264
+ export function isStaleTargetError(error: unknown): boolean {
265
+ if (error instanceof StaleTargetError) return true;
266
+ const message =
267
+ typeof error === "string"
268
+ ? error
269
+ : error instanceof Error
270
+ ? error.message
271
+ : "";
218
272
  return (
219
273
  message.includes("not an object") ||
220
- message.includes("parent is not an object")
274
+ message.includes("parent is not an object") ||
275
+ message.includes("missing key") ||
276
+ message.includes("missing index") ||
277
+ message.includes("does not exist")
221
278
  );
222
279
  }
package/src/pagination.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  * - Page-based: page/per_page with a page number that advances
6
6
  * - Cursor-based: cursor/limit with an opaque next-cursor string
7
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
8
10
  * - Single: one-shot list endpoints that still expose the paginated surface
9
11
  *
10
12
  * Each SDK stores a {@link PaginatedTrait} on its operations (sourced from the
@@ -31,6 +33,33 @@ export const getPath = (obj: unknown, path: string): unknown => {
31
33
  return current;
32
34
  };
33
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
+ };
62
+
34
63
  // ============================================================================
35
64
  // Pagination Trait
36
65
  // ============================================================================
@@ -38,15 +67,26 @@ export const getPath = (obj: unknown, path: string): unknown => {
38
67
  /** Pagination trait describing how to navigate between pages. */
39
68
  export interface PaginatedTrait {
40
69
  /** Pagination strategy */
41
- readonly mode?: "token" | "page" | "cursor" | "single";
70
+ readonly mode?: "token" | "page" | "cursor" | "relay" | "single";
42
71
  /** The name of the input member containing the page/cursor token */
43
72
  readonly inputToken?: string;
44
73
  /** The path to the output member containing the next page/cursor token */
45
74
  readonly outputToken?: string;
46
- /** The path to the output member containing the paginated items */
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
+ */
47
80
  readonly items?: string;
48
81
  /** The name of the input member that limits page size */
49
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;
50
90
  }
51
91
 
52
92
  export type PaginationStrategy = <
@@ -261,6 +301,91 @@ export const paginateToken = <
261
301
  );
262
302
  };
263
303
 
304
+ // ============================================================================
305
+ // Relay Pagination (GraphQL connections)
306
+ // ============================================================================
307
+
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
+ );
387
+ };
388
+
264
389
  /**
265
390
  * Shared default pagination dispatcher for SDKs that use generic
266
391
  * token/cursor/page traversal.
@@ -277,6 +402,8 @@ export const paginateWithDefaults: PaginationStrategy = (
277
402
  return paginatePageNumber(operation, input, pagination);
278
403
  case "cursor":
279
404
  return paginateCursor(operation, input, pagination);
405
+ case "relay":
406
+ return paginateRelay(operation, input, pagination);
280
407
  case "single":
281
408
  return paginateSingle(operation, input, pagination);
282
409
  case "token":
@@ -293,7 +420,8 @@ export const paginateWithDefaults: PaginationStrategy = (
293
420
  * Extracts individual items from a page stream.
294
421
  *
295
422
  * @param pages - A stream of page responses
296
- * @param itemsPath - Dot-separated path to the items array in the page
423
+ * @param itemsPath - Dot-separated path to the items in the page; segments may
424
+ * cross arrays (see {@link getItems})
297
425
  * @returns A Stream of individual items
298
426
  */
299
427
  export const extractItems = <Output, Item, E, R>(
@@ -301,8 +429,7 @@ export const extractItems = <Output, Item, E, R>(
301
429
  itemsPath: string,
302
430
  ): Stream.Stream<Item, E, R> =>
303
431
  pages.pipe(
304
- Stream.flatMap((page) => {
305
- const items = getPath(page, itemsPath) as readonly Item[] | undefined;
306
- return Stream.fromIterable(items ?? []);
307
- }),
432
+ Stream.flatMap((page) =>
433
+ Stream.fromIterable(getItems(page, itemsPath) as readonly Item[]),
434
+ ),
308
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
+ });