@ycadence/sdk 0.0.0-stage → 0.2.0

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/client.ts ADDED
@@ -0,0 +1,550 @@
1
+ /**
2
+ * The YCadence client.
3
+ *
4
+ * Shared by the CLI, the MCP server and anyone integrating from TypeScript, so
5
+ * there is one place that knows how to talk to the API — how errors are shaped,
6
+ * how paging works, how a key is presented, when a request is safe to retry.
7
+ * Three separate implementations of that would disagree, and the disagreement
8
+ * would surface as "the CLI works but the MCP server does not".
9
+ */
10
+
11
+ import { type ApiFailure, createError, RequestIntercepted, YCadenceError } from './errors'
12
+ import { buildPath, isIdempotentMethod, OPERATIONS, type OperationId } from './operations'
13
+ import { paginate } from './pagination'
14
+ import type {
15
+ CreateViewInput,
16
+ CreateWorkItemInput,
17
+ Cycle,
18
+ CycleProgress,
19
+ Health,
20
+ ImportInput,
21
+ ImportPreview,
22
+ ImportRun,
23
+ IntakeItem,
24
+ Label,
25
+ ListWorkItemsOptions,
26
+ Me,
27
+ Page,
28
+ Project,
29
+ State,
30
+ SubmitIntakeInput,
31
+ SubmitIntakeResult,
32
+ TriageInput,
33
+ TriageResult,
34
+ UpdateWorkItemInput,
35
+ View,
36
+ WorkItem,
37
+ WorkItemDetail,
38
+ } from './types'
39
+ import { SDK_VERSION } from './version'
40
+
41
+ export interface RetryOptions {
42
+ /** Attempts after the first. Default 2. `0` turns retrying off. */
43
+ retries?: number
44
+ /** First backoff, doubled each attempt, with jitter. Default 250ms. */
45
+ baseDelayMs?: number
46
+ /** Ceiling for one wait, including a server's `Retry-After`. Default 8s. */
47
+ maxDelayMs?: number
48
+ }
49
+
50
+ /** What happened to one HTTP exchange; for logging. Never carries credentials. */
51
+ export interface ResponseInfo {
52
+ method: string
53
+ path: string
54
+ status: number
55
+ requestId: string | undefined
56
+ durationMs: number
57
+ attempt: number
58
+ replayed: boolean
59
+ }
60
+
61
+ export interface ClientOptions {
62
+ /** e.g. https://ycadence.example.com. Trailing slashes are tolerated. */
63
+ baseUrl: string
64
+ apiKey: string
65
+ /**
66
+ * A Google identity token for an instance behind IAP or an authenticated
67
+ * Cloud Run service, sent as `Authorization: Bearer`. The transport's
68
+ * credential and the product's are different things and travel in different
69
+ * headers: the key always goes in `x-api-key`.
70
+ *
71
+ * A function is called before every request, because these tokens last
72
+ * about an hour and a long-running caller — the MCP server — outlives one.
73
+ */
74
+ identityToken?: string | (() => string | undefined | Promise<string | undefined>)
75
+ /** Defaults to 30s. A request that hangs forever is worse than one that fails. */
76
+ timeoutMs?: number
77
+ fetch?: typeof globalThis.fetch
78
+ /**
79
+ * Retries for 429, 5xx, timeouts and network errors. Only GET requests, and
80
+ * mutations sent with an idempotency key, are retried: repeating any other
81
+ * write could apply it twice.
82
+ */
83
+ retry?: RetryOptions
84
+ /** Prepended to the default `User-Agent`, e.g. `ycadence-cli/0.2.0`. */
85
+ userAgent?: string
86
+ /** Called after every response, including ones that are retried. */
87
+ onResponse?: (info: ResponseInfo) => void
88
+ /** Injected for tests; defaults to a timer. */
89
+ sleep?: (ms: number) => Promise<void>
90
+ }
91
+
92
+ /** Per-call options for any request. */
93
+ export interface RequestOptions {
94
+ /**
95
+ * Sent as `Idempotency-Key` on a mutation, which also makes the mutation
96
+ * safe to retry. Servers that do not support the header ignore it.
97
+ */
98
+ idempotencyKey?: string
99
+ /**
100
+ * The version the caller last saw, sent as `If-Match: "<version>"`. A server
101
+ * that versions the resource answers 409 `version_conflict` or 412 when it
102
+ * has moved on; one that does not ignores the header.
103
+ */
104
+ expectedVersion?: number | string
105
+ signal?: AbortSignal
106
+ /** Overrides the client's retry count for this call. */
107
+ retries?: number
108
+ }
109
+
110
+ const DEFAULT_RETRY: Required<RetryOptions> = { retries: 2, baseDelayMs: 250, maxDelayMs: 8_000 }
111
+
112
+ export class YCadenceClient {
113
+ private readonly baseUrl: string
114
+ private readonly apiKey: string
115
+ private readonly identityToken: ClientOptions['identityToken']
116
+ private readonly timeoutMs: number
117
+ private readonly doFetch: typeof globalThis.fetch
118
+ private readonly retry: Required<RetryOptions>
119
+ private readonly userAgent: string
120
+ private readonly onResponse: ClientOptions['onResponse']
121
+ private readonly sleep: (ms: number) => Promise<void>
122
+
123
+ constructor(private readonly options: ClientOptions) {
124
+ this.baseUrl = options.baseUrl.replace(/\/+$/, '')
125
+ this.apiKey = options.apiKey
126
+ this.identityToken = options.identityToken
127
+ this.timeoutMs = options.timeoutMs ?? 30_000
128
+ this.doFetch =
129
+ options.fetch ?? (((input, init) => globalThis.fetch(input, init)) as typeof globalThis.fetch)
130
+ this.retry = { ...DEFAULT_RETRY, ...options.retry }
131
+ this.userAgent = [options.userAgent, `ycadence-sdk/${SDK_VERSION}`]
132
+ .filter((part) => part !== undefined && part !== '')
133
+ .join(' ')
134
+ this.onResponse = options.onResponse
135
+ this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
136
+ }
137
+
138
+ /** A copy of this client with some options replaced. */
139
+ withOptions(overrides: Partial<ClientOptions>): YCadenceClient {
140
+ return new YCadenceClient({ ...this.options, ...overrides })
141
+ }
142
+
143
+ /** The fetch this client sends with; for wrapping in `withOptions`. */
144
+ get fetchImplementation(): typeof globalThis.fetch {
145
+ return this.doFetch
146
+ }
147
+
148
+ async health(): Promise<Health> {
149
+ return this.call('health')
150
+ }
151
+
152
+ /** Who the credential is. A 404 means the server predates Slice 2A. */
153
+ async me(options?: RequestOptions): Promise<Me> {
154
+ return this.call('me', {}, undefined, undefined, options)
155
+ }
156
+
157
+ /** Projects the credential can read; `q` matches a name or identifier prefix. */
158
+ async listProjects(
159
+ query: { q?: string; archived?: boolean; limit?: number; cursor?: string } = {},
160
+ options?: RequestOptions
161
+ ): Promise<Page<Project>> {
162
+ return this.call('listProjects', {}, query, undefined, options)
163
+ }
164
+
165
+ async listWorkItems(
166
+ projectId: string,
167
+ query: ListWorkItemsOptions = {},
168
+ options?: RequestOptions
169
+ ): Promise<Page<WorkItem>> {
170
+ return this.call(
171
+ 'listWorkItems',
172
+ { projectId },
173
+ {
174
+ filter: query.filter === undefined ? undefined : JSON.stringify(query.filter),
175
+ limit: query.limit,
176
+ cursor: query.cursor,
177
+ state: query.state,
178
+ priority: query.priority,
179
+ },
180
+ undefined,
181
+ options
182
+ )
183
+ }
184
+
185
+ /**
186
+ * Every work item matching a filter, following the cursor.
187
+ *
188
+ * An async generator rather than an array: a project can hold tens of
189
+ * thousands of items, and a caller that wanted the first twenty should not
190
+ * pay for all of them — nor should the process hold them all in memory.
191
+ */
192
+ iterateWorkItems(
193
+ projectId: string,
194
+ options: { filter?: unknown; pageSize?: number } = {}
195
+ ): AsyncGenerator<WorkItem> {
196
+ return paginate((cursor) =>
197
+ this.listWorkItems(projectId, {
198
+ filter: options.filter,
199
+ limit: options.pageSize ?? 100,
200
+ cursor,
201
+ })
202
+ )
203
+ }
204
+
205
+ async createWorkItem(
206
+ projectId: string,
207
+ body: CreateWorkItemInput | Record<string, unknown>,
208
+ options?: RequestOptions
209
+ ): Promise<WorkItem> {
210
+ return this.call('createWorkItem', { projectId }, undefined, body, options)
211
+ }
212
+
213
+ async getWorkItem(workItemId: string, options?: RequestOptions): Promise<WorkItemDetail> {
214
+ return this.call('getWorkItem', { workItemId }, undefined, undefined, options)
215
+ }
216
+
217
+ async updateWorkItem(
218
+ workItemId: string,
219
+ body: UpdateWorkItemInput | Record<string, unknown>,
220
+ options?: RequestOptions
221
+ ): Promise<WorkItem> {
222
+ return this.call('updateWorkItem', { workItemId }, undefined, body, options)
223
+ }
224
+
225
+ async deleteWorkItem(workItemId: string, options?: RequestOptions): Promise<void> {
226
+ await this.call('deleteWorkItem', { workItemId }, undefined, undefined, options)
227
+ }
228
+
229
+ async listStates(projectId: string, options?: RequestOptions): Promise<{ states: State[] }> {
230
+ return this.call('listStates', { projectId }, undefined, undefined, options)
231
+ }
232
+
233
+ async listLabels(projectId: string, options?: RequestOptions): Promise<{ labels: Label[] }> {
234
+ return this.call('listLabels', { projectId }, undefined, undefined, options)
235
+ }
236
+
237
+ async createLabel(
238
+ projectId: string,
239
+ body: { name: string; color: string; description?: string },
240
+ options?: RequestOptions
241
+ ): Promise<Label> {
242
+ return this.call('createLabel', { projectId }, undefined, body, options)
243
+ }
244
+
245
+ async listTypes(projectId: string, options?: RequestOptions): Promise<{ types: unknown[] }> {
246
+ return this.call('listTypes', { projectId }, undefined, undefined, options)
247
+ }
248
+
249
+ async listCycles(projectId: string, options?: RequestOptions): Promise<{ cycles: Cycle[] }> {
250
+ return this.call('listCycles', { projectId }, undefined, undefined, options)
251
+ }
252
+
253
+ async createCycle(
254
+ projectId: string,
255
+ body: { name: string; description?: string; startDate?: string; endDate?: string },
256
+ options?: RequestOptions
257
+ ): Promise<Cycle> {
258
+ return this.call('createCycle', { projectId }, undefined, body, options)
259
+ }
260
+
261
+ async getCycleProgress(cycleId: string, options?: RequestOptions): Promise<CycleProgress> {
262
+ return this.call('getCycleProgress', { cycleId }, undefined, undefined, options)
263
+ }
264
+
265
+ async addWorkItemsToCycle(
266
+ cycleId: string,
267
+ workItemIds: string[],
268
+ options?: RequestOptions
269
+ ): Promise<{ added: number }> {
270
+ return this.call('addWorkItemsToCycle', { cycleId }, undefined, { workItemIds }, options)
271
+ }
272
+
273
+ async listViews(projectId: string, options?: RequestOptions): Promise<{ views: View[] }> {
274
+ return this.call('listViews', { projectId }, undefined, undefined, options)
275
+ }
276
+
277
+ async createView(
278
+ projectId: string,
279
+ body: CreateViewInput,
280
+ options?: RequestOptions
281
+ ): Promise<View> {
282
+ return this.call('createView', { projectId }, undefined, body, options)
283
+ }
284
+
285
+ async listIntake(
286
+ projectId: string,
287
+ query: { includeDecided?: boolean } = {},
288
+ options?: RequestOptions
289
+ ): Promise<{ items: IntakeItem[] }> {
290
+ return this.call('listIntake', { projectId }, query, undefined, options)
291
+ }
292
+
293
+ async submitIntake(
294
+ projectId: string,
295
+ body: SubmitIntakeInput,
296
+ options?: RequestOptions
297
+ ): Promise<SubmitIntakeResult> {
298
+ return this.call('submitIntake', { projectId }, undefined, body, options)
299
+ }
300
+
301
+ async triageIntake(
302
+ intakeItemId: string,
303
+ body: TriageInput,
304
+ options?: RequestOptions
305
+ ): Promise<TriageResult> {
306
+ return this.call('triageIntake', { intakeItemId }, undefined, body, options)
307
+ }
308
+
309
+ async previewImport(
310
+ projectId: string,
311
+ body: ImportInput | Record<string, unknown>,
312
+ options?: RequestOptions
313
+ ): Promise<ImportPreview> {
314
+ return this.call('previewImport', { projectId }, undefined, body, options)
315
+ }
316
+
317
+ async startImport(
318
+ projectId: string,
319
+ body: ImportInput | Record<string, unknown>,
320
+ options?: RequestOptions
321
+ ): Promise<{ runId: string }> {
322
+ return this.call('startImport', { projectId }, undefined, body, options)
323
+ }
324
+
325
+ async getImport(runId: string, options?: RequestOptions): Promise<ImportRun> {
326
+ return this.call('getImport', { runId }, undefined, undefined, options)
327
+ }
328
+
329
+ async listImports(projectId: string, options?: RequestOptions): Promise<{ runs: ImportRun[] }> {
330
+ return this.call('listImports', { projectId }, undefined, undefined, options)
331
+ }
332
+
333
+ /** Call any operation in the table by id. */
334
+ async call<T>(
335
+ operation: OperationId,
336
+ params: Record<string, string> = {},
337
+ query?: Record<string, string | number | boolean | undefined>,
338
+ body?: unknown,
339
+ options: RequestOptions = {}
340
+ ): Promise<T> {
341
+ const { method, path } = OPERATIONS[operation]
342
+ let url = buildPath(path, params)
343
+ if (query !== undefined) {
344
+ const search = new URLSearchParams()
345
+ for (const [name, value] of Object.entries(query)) {
346
+ if (value !== undefined) search.set(name, String(value))
347
+ }
348
+ const encoded = search.toString()
349
+ if (encoded.length > 0) url += `?${encoded}`
350
+ }
351
+ return this.request<T>(method, url, body, options)
352
+ }
353
+
354
+ private async request<T>(
355
+ method: string,
356
+ path: string,
357
+ body: unknown,
358
+ options: RequestOptions
359
+ ): Promise<T> {
360
+ const retryable = isIdempotentMethod(method) || options.idempotencyKey !== undefined
361
+ const retries = retryable ? (options.retries ?? this.retry.retries) : 0
362
+
363
+ for (let attempt = 0; ; attempt += 1) {
364
+ try {
365
+ return await this.send<T>(method, path, body, options, attempt)
366
+ } catch (error) {
367
+ const failure = error as YCadenceError
368
+ const again =
369
+ attempt < retries &&
370
+ typeof failure.retryable === 'boolean' &&
371
+ // 409 request_in_progress: the first request with this key is still
372
+ // running, so asking again later is exactly right.
373
+ (failure.retryable || failure.code === 'request_in_progress')
374
+ if (!again) throw error
375
+ await this.sleep(this.backoff(attempt, failure.retryAfter))
376
+ }
377
+ }
378
+ }
379
+
380
+ private backoff(attempt: number, retryAfter: number | undefined): number {
381
+ if (retryAfter !== undefined) return Math.min(retryAfter * 1000, this.retry.maxDelayMs)
382
+ const exponential = this.retry.baseDelayMs * 2 ** attempt
383
+ // Full jitter on the upper half, so a fleet of clients does not retry in step.
384
+ const jittered = exponential / 2 + Math.random() * (exponential / 2)
385
+ return Math.min(jittered, this.retry.maxDelayMs)
386
+ }
387
+
388
+ private async send<T>(
389
+ method: string,
390
+ path: string,
391
+ body: unknown,
392
+ options: RequestOptions,
393
+ attempt: number
394
+ ): Promise<T> {
395
+ // An explicit timeout, because the default is none: a hung request holds a
396
+ // CLI open until the user gives up, and blocks an agent indefinitely.
397
+ const controller = new AbortController()
398
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs)
399
+ const abort = () => controller.abort()
400
+ options.signal?.addEventListener('abort', abort)
401
+ const started = Date.now()
402
+
403
+ let response: Response
404
+ try {
405
+ const identityToken =
406
+ typeof this.identityToken === 'function' ? await this.identityToken() : this.identityToken
407
+
408
+ const headers: Record<string, string> = {
409
+ // Never Authorization for the key: in front of IAP that header must
410
+ // carry the Google token, and a key there is rejected before the app runs.
411
+ 'x-api-key': this.apiKey,
412
+ ...(identityToken === undefined || identityToken.length === 0
413
+ ? {}
414
+ : { Authorization: `Bearer ${identityToken}` }),
415
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
416
+ Accept: 'application/json',
417
+ 'User-Agent': this.userAgent,
418
+ }
419
+ if (options.idempotencyKey !== undefined && !isIdempotentMethod(method)) {
420
+ headers['Idempotency-Key'] = options.idempotencyKey
421
+ }
422
+ if (options.expectedVersion !== undefined && !isIdempotentMethod(method)) {
423
+ headers['If-Match'] = `"${String(options.expectedVersion).replace(/"/g, '')}"`
424
+ }
425
+
426
+ response = await this.doFetch(`${this.baseUrl}${path}`, {
427
+ method,
428
+ headers,
429
+ body: body === undefined ? undefined : JSON.stringify(body),
430
+ signal: controller.signal,
431
+ })
432
+ } catch (error) {
433
+ if (error instanceof RequestIntercepted) throw error
434
+ if (error instanceof Error && error.name === 'AbortError') {
435
+ throw createError(
436
+ 408,
437
+ 'timeout',
438
+ `${method} ${path} timed out after ${this.timeoutMs}ms.`,
439
+ undefined,
440
+ { method, path }
441
+ )
442
+ }
443
+ throw createError(
444
+ 0,
445
+ 'network_error',
446
+ `Could not reach ${this.baseUrl}: ${error instanceof Error ? error.message : 'unknown error'}`,
447
+ undefined,
448
+ { method, path }
449
+ )
450
+ } finally {
451
+ clearTimeout(timer)
452
+ options.signal?.removeEventListener('abort', abort)
453
+ }
454
+
455
+ const headerRequestId = response.headers?.get?.('x-request-id') ?? undefined
456
+ this.onResponse?.({
457
+ method,
458
+ path,
459
+ status: response.status,
460
+ requestId: headerRequestId,
461
+ durationMs: Date.now() - started,
462
+ attempt,
463
+ replayed: response.headers?.get?.('idempotent-replayed') === 'true',
464
+ })
465
+
466
+ if (response.status === 204) return undefined as T
467
+
468
+ const text = await response.text()
469
+ const parsed = text.length === 0 ? undefined : safeJson(text)
470
+
471
+ if (!response.ok) {
472
+ const failure = (isRecord(parsed) ? parsed : {}) as Partial<ApiFailure>
473
+ throw createError(
474
+ response.status,
475
+ typeof failure.code === 'string' ? failure.code : codeForStatus(response.status),
476
+ // The server's message when there is one; the status only as a fallback,
477
+ // so an unexpected error shape still produces something actionable.
478
+ typeof failure.error === 'string'
479
+ ? failure.error
480
+ : `${method} ${path} failed with ${response.status}.`,
481
+ failure.issues,
482
+ {
483
+ requestId: failure.requestId ?? headerRequestId,
484
+ details: failure.details,
485
+ retryAfter: parseRetryAfter(response.headers?.get?.('retry-after') ?? null),
486
+ method,
487
+ path,
488
+ }
489
+ )
490
+ }
491
+
492
+ return parsed as T
493
+ }
494
+ }
495
+
496
+ function safeJson(text: string): unknown {
497
+ try {
498
+ return JSON.parse(text)
499
+ } catch {
500
+ return undefined
501
+ }
502
+ }
503
+
504
+ function isRecord(value: unknown): value is Record<string, unknown> {
505
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
506
+ }
507
+
508
+ function codeForStatus(status: number): string {
509
+ const codes: Record<number, string> = {
510
+ 400: 'bad_request',
511
+ 401: 'unauthorized',
512
+ 403: 'forbidden',
513
+ 404: 'not_found',
514
+ 409: 'conflict',
515
+ 412: 'precondition_failed',
516
+ 422: 'validation_failed',
517
+ 429: 'rate_limited',
518
+ }
519
+ return codes[status] ?? (status >= 500 ? 'server_error' : 'unknown')
520
+ }
521
+
522
+ /** Seconds, from either form `Retry-After` takes: a number or an HTTP date. */
523
+ export function parseRetryAfter(value: string | null, now = Date.now()): number | undefined {
524
+ if (value === null || value.trim() === '') return undefined
525
+ const seconds = Number(value)
526
+ if (Number.isFinite(seconds)) return Math.max(0, seconds)
527
+ const date = Date.parse(value)
528
+ if (Number.isNaN(date)) return undefined
529
+ return Math.max(0, Math.ceil((date - now) / 1000))
530
+ }
531
+
532
+ export type { ImportPreview, ImportRun, Page, WorkItem } from './types'
533
+
534
+ /**
535
+ * @deprecated Renamed to {@link YCadenceClient}. This alias stays for one
536
+ * release so existing integrations keep compiling.
537
+ */
538
+ export const DevPulseClient = YCadenceClient
539
+ /** @deprecated Renamed to {@link YCadenceClient}. */
540
+ export type DevPulseClient = YCadenceClient
541
+
542
+ export { type ApiFailure, YCadenceError } from './errors'
543
+
544
+ /**
545
+ * @deprecated Renamed to {@link YCadenceError}. The alias is the same class,
546
+ * so `error instanceof DevPulseError` still matches errors the client throws.
547
+ */
548
+ export const DevPulseError = YCadenceError
549
+ /** @deprecated Renamed to {@link YCadenceError}. */
550
+ export type DevPulseError = YCadenceError
package/src/errors.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Errors the client throws.
3
+ *
4
+ * One base class, `YCadenceError`, so `instanceof YCadenceError` catches every
5
+ * failure the client produces, and a subclass per kind of failure, so a caller
6
+ * can branch on the class instead of comparing status codes. The base keeps its
7
+ * original positional constructor `(status, code, message, issues)`, because
8
+ * integrations construct it in their own tests.
9
+ */
10
+
11
+ export interface ApiIssue {
12
+ path: string
13
+ message: string
14
+ }
15
+
16
+ /** The error body every v1 route returns (`packages/api/src/types.ts`). */
17
+ export interface ApiFailure {
18
+ error: string
19
+ code: string
20
+ issues?: ApiIssue[]
21
+ /** Added by the work management contracts; absent on older servers. */
22
+ details?: Record<string, unknown>
23
+ requestId?: string
24
+ }
25
+
26
+ export interface ErrorExtras {
27
+ /** The server's request id, from the body or the `x-request-id` header. */
28
+ requestId?: string
29
+ details?: Record<string, unknown>
30
+ /** Seconds, from `Retry-After`, when the server sent one. */
31
+ retryAfter?: number
32
+ method?: string
33
+ path?: string
34
+ }
35
+
36
+ export class YCadenceError extends Error {
37
+ readonly requestId: string | undefined
38
+ readonly details: Record<string, unknown> | undefined
39
+ readonly retryAfter: number | undefined
40
+ readonly method: string | undefined
41
+ readonly path: string | undefined
42
+
43
+ constructor(
44
+ readonly status: number,
45
+ readonly code: string,
46
+ message: string,
47
+ readonly issues?: ApiIssue[],
48
+ extras: ErrorExtras = {}
49
+ ) {
50
+ super(message)
51
+ this.name = 'YCadenceError'
52
+ this.requestId = extras.requestId
53
+ this.details = extras.details
54
+ this.retryAfter = extras.retryAfter
55
+ this.method = extras.method
56
+ this.path = extras.path
57
+ }
58
+
59
+ /**
60
+ * Whether retrying unchanged could plausibly succeed: rate limits, server
61
+ * errors, timeouts and an unreachable host. A 4xx means the request itself
62
+ * needs to change.
63
+ */
64
+ get retryable(): boolean {
65
+ return this.status === 0 || this.status === 408 || this.status === 429 || this.status >= 500
66
+ }
67
+ }
68
+
69
+ /** 400 or 422: the request was understood and refused as invalid. */
70
+ export class YCadenceValidationError extends YCadenceError {
71
+ override name = 'YCadenceValidationError'
72
+ }
73
+
74
+ /** 401 or 403: no credential, a bad one, or not allowed. */
75
+ export class YCadencePermissionError extends YCadenceError {
76
+ override name = 'YCadencePermissionError'
77
+ }
78
+
79
+ /** 404: missing, deleted, or in another organization. */
80
+ export class YCadenceNotFoundError extends YCadenceError {
81
+ override name = 'YCadenceNotFoundError'
82
+ }
83
+
84
+ /** 409: a domain rule, a version conflict, or the same idempotency key still running. */
85
+ export class YCadenceConflictError extends YCadenceError {
86
+ override name = 'YCadenceConflictError'
87
+ }
88
+
89
+ /** 412: an `If-Match` precondition failed; reload and try again. */
90
+ export class YCadencePreconditionError extends YCadenceError {
91
+ override name = 'YCadencePreconditionError'
92
+ }
93
+
94
+ /** 429: slow down; `retryAfter` says for how long when the server said. */
95
+ export class YCadenceRateLimitError extends YCadenceError {
96
+ override name = 'YCadenceRateLimitError'
97
+ }
98
+
99
+ /** Status 0 or 408: the host could not be reached, or did not answer in time. */
100
+ export class YCadenceNetworkError extends YCadenceError {
101
+ override name = 'YCadenceNetworkError'
102
+ }
103
+
104
+ /** The class that matches a status. */
105
+ export function errorClassFor(status: number): typeof YCadenceError {
106
+ if (status === 0 || status === 408) return YCadenceNetworkError
107
+ if (status === 400 || status === 422) return YCadenceValidationError
108
+ if (status === 401 || status === 403) return YCadencePermissionError
109
+ if (status === 404) return YCadenceNotFoundError
110
+ if (status === 409) return YCadenceConflictError
111
+ if (status === 412) return YCadencePreconditionError
112
+ if (status === 429) return YCadenceRateLimitError
113
+ return YCadenceError
114
+ }
115
+
116
+ export function createError(
117
+ status: number,
118
+ code: string,
119
+ message: string,
120
+ issues?: ApiIssue[],
121
+ extras: ErrorExtras = {}
122
+ ): YCadenceError {
123
+ const ErrorClass = errorClassFor(status)
124
+ return new ErrorClass(status, code, message, issues, extras)
125
+ }
126
+
127
+ /**
128
+ * Thrown, unchanged, through the client by a custom `fetch` that wants to stop
129
+ * a request without it being reported as a network error. The CLI's
130
+ * `--dry-run` uses it to show a mutation instead of sending it.
131
+ */
132
+ export class RequestIntercepted extends Error {
133
+ override name = 'RequestIntercepted'
134
+ constructor(
135
+ readonly request: {
136
+ method: string
137
+ url: string
138
+ headers: Record<string, string>
139
+ body: unknown
140
+ }
141
+ ) {
142
+ super(`${request.method} ${request.url} was not sent.`)
143
+ }
144
+ }