@distilled.cloud/core 0.0.0 → 0.2.0-alpha
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/category.d.ts +260 -0
- package/lib/category.d.ts.map +1 -0
- package/lib/category.js +264 -0
- package/lib/category.js.map +1 -0
- package/lib/client.d.ts +123 -0
- package/lib/client.d.ts.map +1 -0
- package/lib/client.js +268 -0
- package/lib/client.js.map +1 -0
- package/lib/errors.d.ts +181 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +122 -0
- package/lib/errors.js.map +1 -0
- package/lib/json-patch.d.ts +44 -0
- package/lib/json-patch.d.ts.map +1 -0
- package/lib/json-patch.js +208 -0
- package/lib/json-patch.js.map +1 -0
- package/lib/pagination.d.ts +74 -0
- package/lib/pagination.d.ts.map +1 -0
- package/lib/pagination.js +130 -0
- package/lib/pagination.js.map +1 -0
- package/lib/retry.d.ts +99 -0
- package/lib/retry.d.ts.map +1 -0
- package/lib/retry.js +106 -0
- package/lib/retry.js.map +1 -0
- package/lib/sensitive.d.ts +50 -0
- package/lib/sensitive.d.ts.map +1 -0
- package/lib/sensitive.js +64 -0
- package/lib/sensitive.js.map +1 -0
- package/lib/traits.d.ts +265 -0
- package/lib/traits.d.ts.map +1 -0
- package/lib/traits.js +470 -0
- package/lib/traits.js.map +1 -0
- package/package.json +72 -5
- package/src/category.ts +406 -0
- package/src/client.ts +511 -0
- package/src/errors.ts +156 -0
- package/src/json-patch.ts +261 -0
- package/src/pagination.ts +222 -0
- package/src/retry.ts +177 -0
- package/src/sensitive.ts +74 -0
- package/src/traits.ts +627 -0
- package/README.md +0 -15
- package/bun.lock +0 -26
- package/index.ts +0 -1
- package/tsconfig.json +0 -29
package/src/client.ts
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST API Client
|
|
3
|
+
*
|
|
4
|
+
* Provides the core API.make() factory for building typed Effect-based API operations.
|
|
5
|
+
* This is the shared client for REST/OpenAPI-style SDKs (PlanetScale, Neon, GCP).
|
|
6
|
+
*
|
|
7
|
+
* AWS and Cloudflare have their own more specialized client implementations,
|
|
8
|
+
* but they share the same OperationMethod pattern.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { API } from "@distilled.cloud/core/client";
|
|
13
|
+
*
|
|
14
|
+
* const listDatabases = API.make(() => ({
|
|
15
|
+
* inputSchema: ListDatabasesInput,
|
|
16
|
+
* outputSchema: ListDatabasesOutput,
|
|
17
|
+
* errors: [NotFound, Forbidden] as const,
|
|
18
|
+
* }));
|
|
19
|
+
*
|
|
20
|
+
* // Direct call
|
|
21
|
+
* const result = yield* listDatabases({ organization: "my-org" });
|
|
22
|
+
*
|
|
23
|
+
* // Yield first for requirement-free function
|
|
24
|
+
* const fn = yield* listDatabases;
|
|
25
|
+
* const result = yield* fn({ organization: "my-org" });
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
import * as Effect from "effect/Effect";
|
|
29
|
+
import { pipeArguments } from "effect/Pipeable";
|
|
30
|
+
import * as Schema from "effect/Schema";
|
|
31
|
+
import * as AST from "effect/SchemaAST";
|
|
32
|
+
import * as Stream from "effect/Stream";
|
|
33
|
+
import * as HttpBody from "effect/unstable/http/HttpBody";
|
|
34
|
+
import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
35
|
+
import * as HttpClientError from "effect/unstable/http/HttpClientError";
|
|
36
|
+
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
37
|
+
import { SingleShotGen } from "effect/Utils";
|
|
38
|
+
import * as Traits from "./traits.ts";
|
|
39
|
+
import { getPath } from "./traits.ts";
|
|
40
|
+
import type { PaginatedTrait } from "./pagination.ts";
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Client Types
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* An operation that can be used in two ways:
|
|
48
|
+
* 1. Direct call: `yield* operation(input)` - returns Effect with requirements
|
|
49
|
+
* 2. Yield first: `const fn = yield* operation` - captures services, returns requirement-free function
|
|
50
|
+
*/
|
|
51
|
+
export type OperationMethod<I, A, E, R> = Effect.Effect<
|
|
52
|
+
(input: I) => Effect.Effect<A, E, never>,
|
|
53
|
+
never,
|
|
54
|
+
R
|
|
55
|
+
> &
|
|
56
|
+
((input: I) => Effect.Effect<A, E, R>);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A paginated operation that additionally has `.pages()` and `.items()` methods.
|
|
60
|
+
*/
|
|
61
|
+
export type PaginatedOperationMethod<I, A, E, R> = OperationMethod<
|
|
62
|
+
I,
|
|
63
|
+
A,
|
|
64
|
+
E,
|
|
65
|
+
R
|
|
66
|
+
> & {
|
|
67
|
+
pages: (input: I) => Stream.Stream<A, E, R>;
|
|
68
|
+
items: (input: I) => Stream.Stream<unknown, E, R>;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Configuration for the API client factory.
|
|
73
|
+
* SDKs provide this to customize how errors are matched and credentials are applied.
|
|
74
|
+
*/
|
|
75
|
+
export interface ClientConfig<Creds> {
|
|
76
|
+
/** The credentials service tag */
|
|
77
|
+
credentials: {
|
|
78
|
+
new (): Creds;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Get the base URL from credentials */
|
|
82
|
+
getBaseUrl: (creds: Creds) => string;
|
|
83
|
+
|
|
84
|
+
/** Get authorization header(s) from credentials */
|
|
85
|
+
getAuthHeaders: (creds: Creds) => Record<string, string>;
|
|
86
|
+
|
|
87
|
+
/** Match an error response body to a typed error.
|
|
88
|
+
* Should return Effect.fail(error) for known errors,
|
|
89
|
+
* or Effect.fail(fallbackError) for unknown errors.
|
|
90
|
+
* The optional `errors` parameter provides per-operation typed error classes.
|
|
91
|
+
*/
|
|
92
|
+
matchError: (
|
|
93
|
+
status: number,
|
|
94
|
+
body: unknown,
|
|
95
|
+
errors?: readonly ApiErrorClass[],
|
|
96
|
+
) => Effect.Effect<never, unknown>;
|
|
97
|
+
|
|
98
|
+
/** Parse error class for schema decode failures */
|
|
99
|
+
ParseError: new (props: { body: unknown; cause: unknown }) => unknown;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Optional transform applied to the response body before schema decoding.
|
|
103
|
+
* For example, Cloudflare wraps responses in `{ result: <data>, ... }`.
|
|
104
|
+
*/
|
|
105
|
+
transformResponse?: (body: unknown) => unknown;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Base API error type - any error class with at least a _tag and message.
|
|
110
|
+
* Uses `new (...args: any[])` to accommodate error classes with extra fields (e.g. `code`).
|
|
111
|
+
*/
|
|
112
|
+
export type ApiErrorClass = {
|
|
113
|
+
new (...args: any[]): {
|
|
114
|
+
readonly _tag: string;
|
|
115
|
+
readonly message: string;
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Operation configuration with optional operation-specific errors.
|
|
121
|
+
* Supports both `inputSchema`/`outputSchema` and `input`/`output` aliases.
|
|
122
|
+
*/
|
|
123
|
+
export interface OperationConfig<
|
|
124
|
+
I extends Schema.Top,
|
|
125
|
+
O extends Schema.Top,
|
|
126
|
+
E extends readonly ApiErrorClass[] = readonly ApiErrorClass[],
|
|
127
|
+
> {
|
|
128
|
+
inputSchema?: I;
|
|
129
|
+
outputSchema?: O;
|
|
130
|
+
/** Alias for inputSchema (used by Cloudflare/GCP generators) */
|
|
131
|
+
input?: I;
|
|
132
|
+
/** Alias for outputSchema (used by Cloudflare/GCP generators) */
|
|
133
|
+
output?: O;
|
|
134
|
+
errors?: E;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Paginated operation configuration.
|
|
139
|
+
*/
|
|
140
|
+
export interface PaginatedOperationConfig<
|
|
141
|
+
I extends Schema.Top,
|
|
142
|
+
O extends Schema.Top,
|
|
143
|
+
E extends readonly ApiErrorClass[] = readonly ApiErrorClass[],
|
|
144
|
+
> extends OperationConfig<I, O, E> {
|
|
145
|
+
pagination?: PaginatedTrait;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ============================================================================
|
|
149
|
+
// AST Helpers
|
|
150
|
+
// ============================================================================
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check if a schema AST represents an array type.
|
|
154
|
+
* Follows encoding chains and Suspend wrappers.
|
|
155
|
+
*/
|
|
156
|
+
function isArrayAST(ast: AST.AST): boolean {
|
|
157
|
+
if (ast._tag === "Arrays") return true;
|
|
158
|
+
if (ast._tag === "Suspend") return isArrayAST(ast.thunk());
|
|
159
|
+
if (ast.encoding && ast.encoding.length > 0)
|
|
160
|
+
return isArrayAST(ast.encoding[0].to);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ============================================================================
|
|
165
|
+
// Multipart FormData Builder
|
|
166
|
+
// ============================================================================
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Check if a value is a File or Blob.
|
|
170
|
+
*/
|
|
171
|
+
function isFileOrBlob(value: unknown): value is File | Blob {
|
|
172
|
+
return (
|
|
173
|
+
(typeof File !== "undefined" && value instanceof File) ||
|
|
174
|
+
(typeof Blob !== "undefined" && value instanceof Blob)
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Build a FormData from a record of body properties.
|
|
180
|
+
* Handles files/blobs, arrays of files, objects (as JSON blobs), and primitives.
|
|
181
|
+
*
|
|
182
|
+
* This is used for multipart operations (e.g., Cloudflare Workers script uploads)
|
|
183
|
+
* where the body contains a mix of metadata objects and file uploads.
|
|
184
|
+
*/
|
|
185
|
+
function buildFormData(body: Record<string, unknown>): FormData {
|
|
186
|
+
const formData = new FormData();
|
|
187
|
+
|
|
188
|
+
for (const [key, value] of Object.entries(body)) {
|
|
189
|
+
if (value === undefined || value === null) continue;
|
|
190
|
+
|
|
191
|
+
if (isFileOrBlob(value)) {
|
|
192
|
+
// Single file/blob
|
|
193
|
+
formData.append(key, value, value instanceof File ? value.name : key);
|
|
194
|
+
} else if (
|
|
195
|
+
Array.isArray(value) &&
|
|
196
|
+
value.length > 0 &&
|
|
197
|
+
isFileOrBlob(value[0])
|
|
198
|
+
) {
|
|
199
|
+
// Array of files/blobs — append each individually
|
|
200
|
+
for (const file of value) {
|
|
201
|
+
if (isFileOrBlob(file)) {
|
|
202
|
+
formData.append(
|
|
203
|
+
file instanceof File ? file.name : key,
|
|
204
|
+
file,
|
|
205
|
+
file instanceof File ? file.name : undefined,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} else if (typeof value === "object" && value !== null) {
|
|
210
|
+
// Object → append as JSON blob
|
|
211
|
+
formData.append(
|
|
212
|
+
key,
|
|
213
|
+
new Blob([JSON.stringify(value)], { type: "application/json" }),
|
|
214
|
+
key,
|
|
215
|
+
);
|
|
216
|
+
} else {
|
|
217
|
+
// Primitive → append as string
|
|
218
|
+
formData.append(key, String(value));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return formData;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ============================================================================
|
|
226
|
+
// API Client Factory
|
|
227
|
+
// ============================================================================
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Creates an API namespace bound to a specific SDK's client configuration.
|
|
231
|
+
*
|
|
232
|
+
* @example
|
|
233
|
+
* ```ts
|
|
234
|
+
* // In planetscale-sdk/src/client.ts
|
|
235
|
+
* export const API = makeAPI({
|
|
236
|
+
* credentials: Credentials,
|
|
237
|
+
* getBaseUrl: (c) => c.apiBaseUrl,
|
|
238
|
+
* getAuthHeaders: (c) => ({ Authorization: c.token }),
|
|
239
|
+
* matchError: matchPlanetScaleError,
|
|
240
|
+
* ParseError: PlanetScaleParseError,
|
|
241
|
+
* });
|
|
242
|
+
* ```
|
|
243
|
+
*/
|
|
244
|
+
export const makeAPI = <Creds>(config: ClientConfig<Creds>) => {
|
|
245
|
+
type _ClientErrors = HttpClientError.HttpClientError | HttpBody.HttpBodyError;
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
make: <
|
|
249
|
+
I extends Schema.Top,
|
|
250
|
+
O extends Schema.Top,
|
|
251
|
+
const E extends readonly ApiErrorClass[] = readonly [],
|
|
252
|
+
>(
|
|
253
|
+
configFn: () => OperationConfig<I, O, E>,
|
|
254
|
+
): any => {
|
|
255
|
+
const opConfig = configFn();
|
|
256
|
+
// Support both input/output and inputSchema/outputSchema aliases
|
|
257
|
+
const inputSchema = (opConfig.inputSchema ?? opConfig.input)!;
|
|
258
|
+
const outputSchema = (opConfig.outputSchema ?? opConfig.output)!;
|
|
259
|
+
type Input = Schema.Schema.Type<I>;
|
|
260
|
+
|
|
261
|
+
// Read HTTP trait from input schema annotations
|
|
262
|
+
const httpTrait = Traits.getHttpTrait(inputSchema.ast);
|
|
263
|
+
|
|
264
|
+
if (!httpTrait) {
|
|
265
|
+
throw new Error("Input schema must have Http trait");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const method = httpTrait.method;
|
|
269
|
+
|
|
270
|
+
const fn = (input: Input): Effect.Effect<any, any, any> =>
|
|
271
|
+
Effect.gen(function* () {
|
|
272
|
+
const creds = yield* config.credentials as any;
|
|
273
|
+
const client = yield* HttpClient.HttpClient;
|
|
274
|
+
|
|
275
|
+
const baseUrl = config.getBaseUrl(creds as Creds);
|
|
276
|
+
const authHeaders = config.getAuthHeaders(creds as Creds);
|
|
277
|
+
|
|
278
|
+
// Use schema-aware request builder for proper camelCase → wire_name mapping
|
|
279
|
+
const parts = Traits.buildRequestParts(
|
|
280
|
+
inputSchema.ast,
|
|
281
|
+
httpTrait,
|
|
282
|
+
input as Record<string, unknown>,
|
|
283
|
+
inputSchema,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
let request = HttpClientRequest.make(method)(
|
|
287
|
+
baseUrl + parts.path,
|
|
288
|
+
).pipe(
|
|
289
|
+
HttpClientRequest.setHeaders(authHeaders),
|
|
290
|
+
HttpClientRequest.setHeaders(parts.headers),
|
|
291
|
+
HttpClientRequest.setHeader("Accept", "application/json"),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
// Set Content-Type based on body type (skip for FormData — browser sets boundary)
|
|
295
|
+
if (!parts.isMultipart) {
|
|
296
|
+
request = HttpClientRequest.setHeader(
|
|
297
|
+
"Content-Type",
|
|
298
|
+
"application/json",
|
|
299
|
+
)(request);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (Object.keys(parts.query).length > 0) {
|
|
303
|
+
request = HttpClientRequest.setUrlParams(request, parts.query);
|
|
304
|
+
}
|
|
305
|
+
if (method !== "GET" && parts.body !== undefined) {
|
|
306
|
+
if (parts.isMultipart) {
|
|
307
|
+
// Build FormData from body properties for multipart operations
|
|
308
|
+
const formData = buildFormData(
|
|
309
|
+
parts.body as Record<string, unknown>,
|
|
310
|
+
);
|
|
311
|
+
request = HttpClientRequest.setBody(HttpBody.formData(formData))(
|
|
312
|
+
request,
|
|
313
|
+
);
|
|
314
|
+
} else {
|
|
315
|
+
request = yield* HttpClientRequest.bodyJson(parts.body)(request);
|
|
316
|
+
}
|
|
317
|
+
} else if (method === "GET" && parts.body !== undefined) {
|
|
318
|
+
// For GET requests, remaining non-annotated fields go as query params
|
|
319
|
+
const extraQuery: Record<string, string> = {};
|
|
320
|
+
for (const [key, value] of Object.entries(
|
|
321
|
+
parts.body as Record<string, unknown>,
|
|
322
|
+
)) {
|
|
323
|
+
if (value !== undefined) {
|
|
324
|
+
extraQuery[key] = String(value);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (Object.keys(extraQuery).length > 0) {
|
|
328
|
+
request = HttpClientRequest.setUrlParams(request, extraQuery);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const response = yield* client.execute(request).pipe(Effect.scoped);
|
|
333
|
+
|
|
334
|
+
if (response.status >= 400) {
|
|
335
|
+
// Try to parse error body as JSON; fall back to text if not JSON
|
|
336
|
+
const errorBody = yield* response.json.pipe(
|
|
337
|
+
Effect.catchIf(
|
|
338
|
+
() => true,
|
|
339
|
+
() =>
|
|
340
|
+
response.text.pipe(
|
|
341
|
+
Effect.map(
|
|
342
|
+
(text) =>
|
|
343
|
+
({ _nonJsonError: true, body: text }) as unknown,
|
|
344
|
+
),
|
|
345
|
+
Effect.catchIf(
|
|
346
|
+
() => true,
|
|
347
|
+
() =>
|
|
348
|
+
Effect.succeed({
|
|
349
|
+
_nonJsonError: true,
|
|
350
|
+
body: `HTTP ${response.status}`,
|
|
351
|
+
} as unknown),
|
|
352
|
+
),
|
|
353
|
+
),
|
|
354
|
+
),
|
|
355
|
+
);
|
|
356
|
+
return yield* config.matchError(
|
|
357
|
+
response.status,
|
|
358
|
+
errorBody,
|
|
359
|
+
opConfig.errors,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// For void-returning operations (e.g. DELETE 204 No Content)
|
|
364
|
+
if (AST.isVoid(outputSchema.ast)) {
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// For 204 No Content: if schema is not Unknown, return undefined.
|
|
369
|
+
// If schema IS Unknown, return empty string (so callers get a defined value).
|
|
370
|
+
if (response.status === 204) {
|
|
371
|
+
if (outputSchema.ast._tag === "Unknown") {
|
|
372
|
+
return "";
|
|
373
|
+
}
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Try to parse response as JSON; fall back to text for non-JSON responses
|
|
378
|
+
// (e.g., multipart/form-data worker scripts, raw KV values)
|
|
379
|
+
const rawBody = yield* response.json.pipe(
|
|
380
|
+
Effect.catchIf(
|
|
381
|
+
() => true,
|
|
382
|
+
() => response.text.pipe(Effect.map((text) => text as unknown)),
|
|
383
|
+
),
|
|
384
|
+
);
|
|
385
|
+
let responseBody = config.transformResponse
|
|
386
|
+
? config.transformResponse(rawBody)
|
|
387
|
+
: rawBody;
|
|
388
|
+
|
|
389
|
+
// Handle Cloudflare-style paginated responses where result is
|
|
390
|
+
// { items: [...] } but the schema expects an array
|
|
391
|
+
if (
|
|
392
|
+
isArrayAST(outputSchema.ast) &&
|
|
393
|
+
!Array.isArray(responseBody) &&
|
|
394
|
+
typeof responseBody === "object" &&
|
|
395
|
+
responseBody !== null &&
|
|
396
|
+
"items" in responseBody &&
|
|
397
|
+
Array.isArray((responseBody as Record<string, unknown>).items)
|
|
398
|
+
) {
|
|
399
|
+
responseBody = (responseBody as Record<string, unknown>).items;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return yield* Schema.decodeUnknownEffect(outputSchema)(
|
|
403
|
+
responseBody,
|
|
404
|
+
).pipe(
|
|
405
|
+
Effect.catchTag("SchemaError", (cause) =>
|
|
406
|
+
Effect.fail(new config.ParseError({ body: rawBody, cause })),
|
|
407
|
+
),
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const Proto = {
|
|
412
|
+
[Symbol.iterator]() {
|
|
413
|
+
return new SingleShotGen(this);
|
|
414
|
+
},
|
|
415
|
+
pipe() {
|
|
416
|
+
return pipeArguments(this.asEffect(), arguments);
|
|
417
|
+
},
|
|
418
|
+
asEffect() {
|
|
419
|
+
return Effect.map(
|
|
420
|
+
Effect.services(),
|
|
421
|
+
(sm) => (input: Input) => fn(input).pipe(Effect.provide(sm)),
|
|
422
|
+
);
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
return Object.assign(fn, Proto);
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
makePaginated: <
|
|
430
|
+
I extends Schema.Top,
|
|
431
|
+
O extends Schema.Top,
|
|
432
|
+
const E extends readonly ApiErrorClass[] = readonly [],
|
|
433
|
+
>(
|
|
434
|
+
configFn: () => PaginatedOperationConfig<I, O, E>,
|
|
435
|
+
paginateFn?: (
|
|
436
|
+
baseFn: (input: any) => Effect.Effect<any, any, any>,
|
|
437
|
+
input: any,
|
|
438
|
+
pagination: PaginatedTrait,
|
|
439
|
+
) => Stream.Stream<any, any, any>,
|
|
440
|
+
): any => {
|
|
441
|
+
const opConfig = configFn();
|
|
442
|
+
const pagination = opConfig.pagination!;
|
|
443
|
+
|
|
444
|
+
// Create the base operation
|
|
445
|
+
const baseFn = makeAPI(config).make(() => ({
|
|
446
|
+
inputSchema: opConfig.inputSchema ?? opConfig.input,
|
|
447
|
+
outputSchema: opConfig.outputSchema ?? opConfig.output,
|
|
448
|
+
errors: opConfig.errors,
|
|
449
|
+
}));
|
|
450
|
+
|
|
451
|
+
type Input = Schema.Schema.Type<I>;
|
|
452
|
+
|
|
453
|
+
// Default pagination: token-based (works for Cloudflare/GCP style)
|
|
454
|
+
const defaultPaginateFn = (
|
|
455
|
+
op: (input: any) => Effect.Effect<any, any, any>,
|
|
456
|
+
input: any,
|
|
457
|
+
pag: PaginatedTrait,
|
|
458
|
+
): Stream.Stream<any, any, any> => {
|
|
459
|
+
type State = { token: unknown; done: boolean };
|
|
460
|
+
return Stream.unfold(
|
|
461
|
+
{ token: undefined, done: false } as State,
|
|
462
|
+
(state: State) =>
|
|
463
|
+
Effect.gen(function* () {
|
|
464
|
+
if (state.done) return undefined;
|
|
465
|
+
const requestPayload =
|
|
466
|
+
state.token !== undefined
|
|
467
|
+
? { ...input, [pag.inputToken]: state.token }
|
|
468
|
+
: input;
|
|
469
|
+
const response = yield* op(requestPayload);
|
|
470
|
+
const nextToken = getPath(response, pag.outputToken);
|
|
471
|
+
return [
|
|
472
|
+
response,
|
|
473
|
+
{
|
|
474
|
+
token: nextToken,
|
|
475
|
+
done: nextToken === undefined || nextToken === null,
|
|
476
|
+
},
|
|
477
|
+
] as const;
|
|
478
|
+
}),
|
|
479
|
+
);
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const paginate = paginateFn ?? defaultPaginateFn;
|
|
483
|
+
|
|
484
|
+
// Stream all pages
|
|
485
|
+
const pagesFn = (input: Omit<Input, string>) =>
|
|
486
|
+
paginate(baseFn, input, pagination);
|
|
487
|
+
|
|
488
|
+
// Stream individual items
|
|
489
|
+
const itemsFn = (input: Omit<Input, string>) =>
|
|
490
|
+
pagesFn(input).pipe(
|
|
491
|
+
Stream.flatMap((page) => {
|
|
492
|
+
if (!pagination.items) return Stream.make(page);
|
|
493
|
+
const items = getPath(page, pagination.items) as
|
|
494
|
+
| readonly unknown[]
|
|
495
|
+
| undefined;
|
|
496
|
+
return Stream.fromIterable(items ?? []);
|
|
497
|
+
}),
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
const result = baseFn as typeof baseFn & {
|
|
501
|
+
pages: typeof pagesFn;
|
|
502
|
+
items: typeof itemsFn;
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
result.pages = pagesFn;
|
|
506
|
+
result.items = itemsFn;
|
|
507
|
+
|
|
508
|
+
return result;
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
};
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common error types shared across SDKs.
|
|
3
|
+
*
|
|
4
|
+
* Each SDK defines its own provider-specific errors (e.g., Unauthorized, NotFound)
|
|
5
|
+
* using the category system. This module provides base error types and utilities
|
|
6
|
+
* that are used across all SDKs.
|
|
7
|
+
*/
|
|
8
|
+
import * as Schema from "effect/Schema";
|
|
9
|
+
import * as Category from "./category.ts";
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Common HTTP Status Error Classes
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Unauthorized - Authentication failure (401).
|
|
17
|
+
*/
|
|
18
|
+
export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
|
|
19
|
+
"Unauthorized",
|
|
20
|
+
{ message: Schema.String },
|
|
21
|
+
).pipe(Category.withAuthError) {}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Forbidden - Access denied (403).
|
|
25
|
+
*/
|
|
26
|
+
export class Forbidden extends Schema.TaggedErrorClass<Forbidden>()(
|
|
27
|
+
"Forbidden",
|
|
28
|
+
{ message: Schema.String },
|
|
29
|
+
).pipe(Category.withAuthError) {}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* NotFound - Resource not found (404).
|
|
33
|
+
*/
|
|
34
|
+
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("NotFound", {
|
|
35
|
+
message: Schema.String,
|
|
36
|
+
}).pipe(Category.withNotFoundError) {}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* BadRequest - Invalid request (400).
|
|
40
|
+
*/
|
|
41
|
+
export class BadRequest extends Schema.TaggedErrorClass<BadRequest>()(
|
|
42
|
+
"BadRequest",
|
|
43
|
+
{ message: Schema.String },
|
|
44
|
+
).pipe(Category.withBadRequestError) {}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Conflict - Resource conflict (409).
|
|
48
|
+
*/
|
|
49
|
+
export class Conflict extends Schema.TaggedErrorClass<Conflict>()("Conflict", {
|
|
50
|
+
message: Schema.String,
|
|
51
|
+
}).pipe(Category.withConflictError) {}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* UnprocessableEntity - Validation error (422).
|
|
55
|
+
*/
|
|
56
|
+
export class UnprocessableEntity extends Schema.TaggedErrorClass<UnprocessableEntity>()(
|
|
57
|
+
"UnprocessableEntity",
|
|
58
|
+
{ message: Schema.String },
|
|
59
|
+
).pipe(Category.withBadRequestError) {}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* TooManyRequests - Rate limited (429).
|
|
63
|
+
*/
|
|
64
|
+
export class TooManyRequests extends Schema.TaggedErrorClass<TooManyRequests>()(
|
|
65
|
+
"TooManyRequests",
|
|
66
|
+
{ message: Schema.String },
|
|
67
|
+
).pipe(
|
|
68
|
+
Category.withThrottlingError,
|
|
69
|
+
Category.withRetryable({ throttling: true }),
|
|
70
|
+
) {}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Locked - Resource locked (423).
|
|
74
|
+
*/
|
|
75
|
+
export class Locked extends Schema.TaggedErrorClass<Locked>()("Locked", {
|
|
76
|
+
message: Schema.String,
|
|
77
|
+
}).pipe(Category.withLockedError, Category.withRetryable()) {}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* InternalServerError - Server error (500).
|
|
81
|
+
*/
|
|
82
|
+
export class InternalServerError extends Schema.TaggedErrorClass<InternalServerError>()(
|
|
83
|
+
"InternalServerError",
|
|
84
|
+
{ message: Schema.String },
|
|
85
|
+
).pipe(Category.withServerError, Category.withRetryable()) {}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* ServiceUnavailable - Service unavailable (503).
|
|
89
|
+
*/
|
|
90
|
+
export class ServiceUnavailable extends Schema.TaggedErrorClass<ServiceUnavailable>()(
|
|
91
|
+
"ServiceUnavailable",
|
|
92
|
+
{ message: Schema.String },
|
|
93
|
+
).pipe(Category.withServerError, Category.withRetryable()) {}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Configuration error - missing or invalid configuration.
|
|
97
|
+
*/
|
|
98
|
+
export class ConfigError extends Schema.TaggedErrorClass<ConfigError>()(
|
|
99
|
+
"ConfigError",
|
|
100
|
+
{ message: Schema.String },
|
|
101
|
+
).pipe(Category.withConfigurationError) {}
|
|
102
|
+
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// Error Maps
|
|
105
|
+
// ============================================================================
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Mapping from HTTP status codes to common error classes.
|
|
109
|
+
*/
|
|
110
|
+
export const HTTP_STATUS_MAP = {
|
|
111
|
+
400: BadRequest,
|
|
112
|
+
401: Unauthorized,
|
|
113
|
+
403: Forbidden,
|
|
114
|
+
404: NotFound,
|
|
115
|
+
409: Conflict,
|
|
116
|
+
422: UnprocessableEntity,
|
|
117
|
+
423: Locked,
|
|
118
|
+
429: TooManyRequests,
|
|
119
|
+
500: InternalServerError,
|
|
120
|
+
503: ServiceUnavailable,
|
|
121
|
+
} as const;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* HTTP status codes that are considered "default" errors (always present).
|
|
125
|
+
* These are excluded from per-operation error types since they're handled globally.
|
|
126
|
+
*/
|
|
127
|
+
export const DEFAULT_ERROR_STATUSES = new Set([401, 429, 500, 503]);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* All common API error classes.
|
|
131
|
+
*/
|
|
132
|
+
export const API_ERRORS = [
|
|
133
|
+
Unauthorized,
|
|
134
|
+
Forbidden,
|
|
135
|
+
NotFound,
|
|
136
|
+
BadRequest,
|
|
137
|
+
Conflict,
|
|
138
|
+
UnprocessableEntity,
|
|
139
|
+
TooManyRequests,
|
|
140
|
+
Locked,
|
|
141
|
+
InternalServerError,
|
|
142
|
+
ServiceUnavailable,
|
|
143
|
+
] as const;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Default errors that apply to ALL operations.
|
|
147
|
+
* These are infrastructure-level errors that can occur regardless of the operation.
|
|
148
|
+
*/
|
|
149
|
+
export const DEFAULT_ERRORS = [
|
|
150
|
+
Unauthorized,
|
|
151
|
+
TooManyRequests,
|
|
152
|
+
InternalServerError,
|
|
153
|
+
ServiceUnavailable,
|
|
154
|
+
] as const;
|
|
155
|
+
|
|
156
|
+
export type DefaultErrors = InstanceType<(typeof DEFAULT_ERRORS)[number]>;
|