@distilled.cloud/core 1.0.0-rc.2 → 1.0.0-rc.4

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/src/errors.ts CHANGED
@@ -33,7 +33,7 @@ export const DurationSchema = Schema.declare<Duration.Duration>(
33
33
  /**
34
34
  * Unauthorized - Authentication failure (401).
35
35
  */
36
- export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
36
+ export class Unauthorized extends Schema.TaggedError<Unauthorized>()(
37
37
  "Unauthorized",
38
38
  { message: Schema.String },
39
39
  ).pipe(Category.withAuthError) {}
@@ -41,37 +41,35 @@ export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
41
41
  /**
42
42
  * Forbidden - Access denied (403).
43
43
  */
44
- export class Forbidden extends Schema.TaggedErrorClass<Forbidden>()(
45
- "Forbidden",
46
- { message: Schema.String },
47
- ).pipe(Category.withAuthError) {}
44
+ export class Forbidden extends Schema.TaggedError<Forbidden>()("Forbidden", {
45
+ message: Schema.String,
46
+ }).pipe(Category.withAuthError) {}
48
47
 
49
48
  /**
50
49
  * NotFound - Resource not found (404).
51
50
  */
52
- export class NotFound extends Schema.TaggedErrorClass<NotFound>()("NotFound", {
51
+ export class NotFound extends Schema.TaggedError<NotFound>()("NotFound", {
53
52
  message: Schema.String,
54
53
  }).pipe(Category.withNotFoundError) {}
55
54
 
56
55
  /**
57
56
  * BadRequest - Invalid request (400).
58
57
  */
59
- export class BadRequest extends Schema.TaggedErrorClass<BadRequest>()(
60
- "BadRequest",
61
- { message: Schema.String },
62
- ).pipe(Category.withBadRequestError) {}
58
+ export class BadRequest extends Schema.TaggedError<BadRequest>()("BadRequest", {
59
+ message: Schema.String,
60
+ }).pipe(Category.withBadRequestError) {}
63
61
 
64
62
  /**
65
63
  * Conflict - Resource conflict (409).
66
64
  */
67
- export class Conflict extends Schema.TaggedErrorClass<Conflict>()("Conflict", {
65
+ export class Conflict extends Schema.TaggedError<Conflict>()("Conflict", {
68
66
  message: Schema.String,
69
67
  }).pipe(Category.withConflictError) {}
70
68
 
71
69
  /**
72
70
  * UnprocessableEntity - Validation error (422).
73
71
  */
74
- export class UnprocessableEntity extends Schema.TaggedErrorClass<UnprocessableEntity>()(
72
+ export class UnprocessableEntity extends Schema.TaggedError<UnprocessableEntity>()(
75
73
  "UnprocessableEntity",
76
74
  { message: Schema.String },
77
75
  ).pipe(Category.withBadRequestError) {}
@@ -79,7 +77,7 @@ export class UnprocessableEntity extends Schema.TaggedErrorClass<UnprocessableEn
79
77
  /**
80
78
  * TooManyRequests - Rate limited (429).
81
79
  */
82
- export class TooManyRequests extends Schema.TaggedErrorClass<TooManyRequests>()(
80
+ export class TooManyRequests extends Schema.TaggedError<TooManyRequests>()(
83
81
  "TooManyRequests",
84
82
  {
85
83
  message: Schema.String,
@@ -93,7 +91,7 @@ export class TooManyRequests extends Schema.TaggedErrorClass<TooManyRequests>()(
93
91
  /**
94
92
  * Locked - Resource locked (423).
95
93
  */
96
- export class Locked extends Schema.TaggedErrorClass<Locked>()("Locked", {
94
+ export class Locked extends Schema.TaggedError<Locked>()("Locked", {
97
95
  message: Schema.String,
98
96
  retryAfter: Schema.optional(DurationSchema),
99
97
  }).pipe(Category.withLockedError, Category.withRetryable()) {}
@@ -106,7 +104,7 @@ export class Locked extends Schema.TaggedErrorClass<Locked>()("Locked", {
106
104
  * 200 envelope carrying `{ code: 10002, message: "An unknown error has
107
105
  * occurred" }` for transient backend failures on some endpoints).
108
106
  */
109
- export class InternalServerError extends Schema.TaggedErrorClass<InternalServerError>()(
107
+ export class InternalServerError extends Schema.TaggedError<InternalServerError>()(
110
108
  "InternalServerError",
111
109
  {
112
110
  message: Schema.String,
@@ -118,18 +116,15 @@ export class InternalServerError extends Schema.TaggedErrorClass<InternalServerE
118
116
  /**
119
117
  * BadGateway - Bad gateway (502).
120
118
  */
121
- export class BadGateway extends Schema.TaggedErrorClass<BadGateway>()(
122
- "BadGateway",
123
- {
124
- message: Schema.String,
125
- retryAfter: Schema.optional(DurationSchema),
126
- },
127
- ).pipe(Category.withServerError, Category.withRetryable()) {}
119
+ export class BadGateway extends Schema.TaggedError<BadGateway>()("BadGateway", {
120
+ message: Schema.String,
121
+ retryAfter: Schema.optional(DurationSchema),
122
+ }).pipe(Category.withServerError, Category.withRetryable()) {}
128
123
 
129
124
  /**
130
125
  * ServiceUnavailable - Service unavailable (503).
131
126
  */
132
- export class ServiceUnavailable extends Schema.TaggedErrorClass<ServiceUnavailable>()(
127
+ export class ServiceUnavailable extends Schema.TaggedError<ServiceUnavailable>()(
133
128
  "ServiceUnavailable",
134
129
  {
135
130
  message: Schema.String,
@@ -140,7 +135,7 @@ export class ServiceUnavailable extends Schema.TaggedErrorClass<ServiceUnavailab
140
135
  /**
141
136
  * GatewayTimeout - Gateway timeout (504).
142
137
  */
143
- export class GatewayTimeout extends Schema.TaggedErrorClass<GatewayTimeout>()(
138
+ export class GatewayTimeout extends Schema.TaggedError<GatewayTimeout>()(
144
139
  "GatewayTimeout",
145
140
  {
146
141
  message: Schema.String,
@@ -151,7 +146,7 @@ export class GatewayTimeout extends Schema.TaggedErrorClass<GatewayTimeout>()(
151
146
  /**
152
147
  * Configuration error - missing or invalid configuration.
153
148
  */
154
- export class ConfigError extends Schema.TaggedErrorClass<ConfigError>()(
149
+ export class ConfigError extends Schema.TaggedError<ConfigError>()(
155
150
  "ConfigError",
156
151
  { message: Schema.String },
157
152
  ).pipe(Category.withConfigurationError) {}
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,82 @@ 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
+
370
+ const nextState: State = {
371
+ cursor: nextCursor ?? undefined,
372
+ done: !hasNext || isTerminalToken(nextCursor) || emptyPage,
373
+ };
374
+
375
+ return [response, nextState] as const;
376
+ }),
377
+ );
378
+ };
379
+
264
380
  /**
265
381
  * Shared default pagination dispatcher for SDKs that use generic
266
382
  * token/cursor/page traversal.
@@ -277,6 +393,8 @@ export const paginateWithDefaults: PaginationStrategy = (
277
393
  return paginatePageNumber(operation, input, pagination);
278
394
  case "cursor":
279
395
  return paginateCursor(operation, input, pagination);
396
+ case "relay":
397
+ return paginateRelay(operation, input, pagination);
280
398
  case "single":
281
399
  return paginateSingle(operation, input, pagination);
282
400
  case "token":
@@ -293,7 +411,8 @@ export const paginateWithDefaults: PaginationStrategy = (
293
411
  * Extracts individual items from a page stream.
294
412
  *
295
413
  * @param pages - A stream of page responses
296
- * @param itemsPath - Dot-separated path to the items array in the page
414
+ * @param itemsPath - Dot-separated path to the items in the page; segments may
415
+ * cross arrays (see {@link getItems})
297
416
  * @returns A Stream of individual items
298
417
  */
299
418
  export const extractItems = <Output, Item, E, R>(
@@ -301,8 +420,7 @@ export const extractItems = <Output, Item, E, R>(
301
420
  itemsPath: string,
302
421
  ): Stream.Stream<Item, E, R> =>
303
422
  pages.pipe(
304
- Stream.flatMap((page) => {
305
- const items = getPath(page, itemsPath) as readonly Item[] | undefined;
306
- return Stream.fromIterable(items ?? []);
307
- }),
423
+ Stream.flatMap((page) =>
424
+ Stream.fromIterable(getItems(page, itemsPath) as readonly Item[]),
425
+ ),
308
426
  );
@@ -616,9 +616,17 @@ export const buildRequest = ({
616
616
  request = request.pipe(HttpClientRequest.bodyJsonUnsafe(rawBody));
617
617
  }
618
618
  } else if (
619
- !BODYLESS.has(http.method) &&
620
- (Object.keys(body).length > 0 ||
621
- (hasBodyMembers && http.method !== "DELETE"))
619
+ // A GET/HEAD sends a body only when body members actually carry values.
620
+ // Some APIs really do document GET-with-body: MongoDB Atlas's
621
+ // `…/lineItems:search` declares a `requestBody` on `get`, and AWS EFS's
622
+ // DescribeAccountPreferences takes `{ MaxResults, NextToken }` on a GET.
623
+ // Suppressing it outright dropped those members silently — the request
624
+ // succeeded, unfiltered, with nothing on the wire to show why.
625
+ //
626
+ // The `{}`-when-empty rule below stays limited to methods that expect a
627
+ // body, so a bodyless method with nothing set still sends nothing.
628
+ Object.keys(body).length > 0 ||
629
+ (hasBodyMembers && !BODYLESS.has(http.method) && http.method !== "DELETE")
622
630
  ) {
623
631
  // Send `{}` rather than no body when the schema declares body members —
624
632
  // some endpoints reject a missing JSON body outright.
package/src/schema.ts CHANGED
@@ -48,6 +48,6 @@ export const Null: any = S.Null;
48
48
  export const Void: any = S.Void;
49
49
  export const Any: any = S.Any;
50
50
 
51
- // NOTE: `Schema` / `Codec` (types) and `TaggedErrorClass` are intentionally NOT
51
+ // NOTE: `Schema` / `Codec` (types) and `TaggedError` are intentionally NOT
52
52
  // overridden — they flow through `export *` with real types so cast targets and
53
53
  // typed error classes stay precise.