@wavyx/pdcli 0.8.0 → 0.10.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/lib/client.js CHANGED
@@ -79,6 +79,87 @@ export function createClient({
79
79
  }
80
80
  }
81
81
 
82
+ return transport(method, url, {
83
+ path,
84
+ makeBody: body ? () => JSON.stringify(body) : undefined,
85
+ extraHeaders: { 'content-type': 'application/json' },
86
+ })
87
+ }
88
+
89
+ /**
90
+ * v2 cursor pagination: cursor/limit → additional_data.next_cursor.
91
+ * @param {string} path
92
+ * @param {object} [query]
93
+ * @returns {AsyncGenerator<object>}
94
+ */
95
+ async function* pageV2(path, query = {}) {
96
+ let cursor
97
+ const baseQuery = clampLimit(query)
98
+ while (true) {
99
+ const envelope = await request('GET', path, {
100
+ query: cursor ? { ...baseQuery, cursor } : baseQuery,
101
+ })
102
+ yield* envelope?.data ?? []
103
+ cursor = envelope?.additional_data?.next_cursor
104
+ if (!cursor) break
105
+ }
106
+ }
107
+
108
+ /**
109
+ * v1 offset pagination: start/limit →
110
+ * additional_data.pagination.{more_items_in_collection,next_start}.
111
+ * @param {string} path
112
+ * @param {object} [query]
113
+ * @returns {AsyncGenerator<object>}
114
+ */
115
+ async function* pageV1(path, query = {}) {
116
+ let start
117
+ const baseQuery = clampLimit(query)
118
+ while (true) {
119
+ const envelope = await request('GET', path, {
120
+ query: start != null ? { ...baseQuery, start } : baseQuery,
121
+ })
122
+ yield* envelope?.data ?? []
123
+ const pagination = envelope?.additional_data?.pagination
124
+ if (!pagination?.more_items_in_collection) break
125
+ start = pagination.next_start
126
+ }
127
+ }
128
+
129
+ function lockedUrl(path) {
130
+ const url = new URL(path, baseOrigin)
131
+ if (url.origin !== baseOrigin) {
132
+ throw new CliError(
133
+ `Refusing to send request outside your Pipedrive company host ` +
134
+ `(${baseOrigin}): ${url.origin}`,
135
+ { exitCode: 78 },
136
+ )
137
+ }
138
+ return url
139
+ }
140
+
141
+ function authHeaders() {
142
+ return authMode === 'oauth'
143
+ ? { authorization: `Bearer ${token}`, 'user-agent': userAgent }
144
+ : { 'x-api-token': token, 'user-agent': userAgent }
145
+ }
146
+
147
+ /**
148
+ * Shared transport: EVERY HTTP path (JSON, form, multipart, binary) goes
149
+ * through the same 429 backoff (x-ratelimit-reset → Retry-After → default),
150
+ * 429→403 escalation hard stop, 5xx retry, OAuth refresh-once, and error
151
+ * mapping. `makeBody` is invoked per attempt — FormData and friends are
152
+ * rebuilt rather than reused across retries.
153
+ * @param {string} method
154
+ * @param {URL} url
155
+ * @param {{ path: string, makeBody?: () => any,
156
+ * extraHeaders?: Record<string, string>, binary?: boolean }} options
157
+ */
158
+ async function transport(
159
+ method,
160
+ url,
161
+ { path, makeBody, extraHeaders = {}, binary = false },
162
+ ) {
82
163
  const maxAttempts = retry ? 3 : 1
83
164
  let attempts = 0
84
165
  let sawRateLimit = false
@@ -86,26 +167,31 @@ export function createClient({
86
167
  while (attempts < maxAttempts) {
87
168
  attempts++
88
169
 
89
- const headers = {
90
- 'content-type': 'application/json',
91
- 'user-agent': userAgent,
92
- }
93
- if (authMode === 'oauth') {
94
- headers.authorization = `Bearer ${token}`
95
- } else {
96
- headers['x-api-token'] = token
97
- }
98
-
99
170
  const res = await fetch(url, {
100
171
  method,
101
- headers,
102
- body: body ? JSON.stringify(body) : undefined,
172
+ headers: { ...authHeaders(), ...extraHeaders },
173
+ body: makeBody ? makeBody() : undefined,
103
174
  signal: AbortSignal.timeout(timeout),
104
175
  })
105
176
 
106
177
  debug('%s %s → %d', method, path, res.status)
107
178
 
108
179
  if (res.status === 429) {
180
+ // Daily-budget exhaustion has no useful reset window — backoff would
181
+ // stall until the daily reset. Fail fast with an actionable message.
182
+ // The live API reports the token budget as
183
+ // x-daily-ratelimit-token-remaining (verified on the sandbox);
184
+ // x-daily-requests-left is the older POST/PUT fair-use header.
185
+ const dailyRemaining =
186
+ res.headers.get('x-daily-ratelimit-token-remaining') ??
187
+ res.headers.get('x-daily-requests-left')
188
+ if (dailyRemaining === '0') {
189
+ const err = new RateLimitError(0)
190
+ err.message =
191
+ 'Daily API token budget exhausted — resets at midnight server ' +
192
+ 'time (UTC-based; may differ from your local timezone)'
193
+ throw err
194
+ }
109
195
  const wait = Number(
110
196
  res.headers.get('x-ratelimit-reset') ||
111
197
  res.headers.get('retry-after') ||
@@ -118,6 +204,16 @@ export function createClient({
118
204
  continue
119
205
  }
120
206
 
207
+ // Surface the remaining daily budget under --verbose (DEBUG=pd:*).
208
+ const dailyLeft = res.headers.get('x-daily-ratelimit-token-remaining')
209
+ if (dailyLeft != null) {
210
+ debug(
211
+ 'daily token budget: %s remaining of %s',
212
+ dailyLeft,
213
+ res.headers.get('x-daily-ratelimit-token-limit') ?? '?',
214
+ )
215
+ }
216
+
121
217
  // OAuth access tokens expire (~1h) — refresh once and retry.
122
218
  if (res.status === 401 && onRefresh && attempts === 1) {
123
219
  debug('401, attempting OAuth token refresh')
@@ -141,97 +237,38 @@ export function createClient({
141
237
  continue
142
238
  }
143
239
 
240
+ // Binary callers always get the {buffer, contentType} shape — a 204
241
+ // yields an empty buffer rather than null (pre-unification parity;
242
+ // file/download destructures the result).
243
+ if (binary) {
244
+ if (!res.ok) {
245
+ throw ApiError.fromResponse(res.status, await res.text(), path)
246
+ }
247
+ return {
248
+ buffer: await res.arrayBuffer(),
249
+ contentType: res.headers.get('content-type'),
250
+ }
251
+ }
252
+
144
253
  if (res.status === 204) return null
145
254
 
146
255
  const text = await res.text()
147
-
148
256
  if (!res.ok) {
149
257
  throw ApiError.fromResponse(res.status, text, path)
150
258
  }
151
-
152
259
  return text ? JSON.parse(text) : null
153
260
  }
154
261
 
155
262
  throw new ServiceUnavailableError()
156
263
  }
157
264
 
158
- /**
159
- * v2 cursor pagination: cursor/limit → additional_data.next_cursor.
160
- * @param {string} path
161
- * @param {object} [query]
162
- * @returns {AsyncGenerator<object>}
163
- */
164
- async function* pageV2(path, query = {}) {
165
- let cursor
166
- const baseQuery = clampLimit(query)
167
- while (true) {
168
- const envelope = await request('GET', path, {
169
- query: cursor ? { ...baseQuery, cursor } : baseQuery,
170
- })
171
- yield* envelope?.data ?? []
172
- cursor = envelope?.additional_data?.next_cursor
173
- if (!cursor) break
174
- }
175
- }
176
-
177
- /**
178
- * v1 offset pagination: start/limit →
179
- * additional_data.pagination.{more_items_in_collection,next_start}.
180
- * @param {string} path
181
- * @param {object} [query]
182
- * @returns {AsyncGenerator<object>}
183
- */
184
- async function* pageV1(path, query = {}) {
185
- let start
186
- const baseQuery = clampLimit(query)
187
- while (true) {
188
- const envelope = await request('GET', path, {
189
- query: start != null ? { ...baseQuery, start } : baseQuery,
190
- })
191
- yield* envelope?.data ?? []
192
- const pagination = envelope?.additional_data?.pagination
193
- if (!pagination?.more_items_in_collection) break
194
- start = pagination.next_start
195
- }
196
- }
197
-
198
- function lockedUrl(path) {
199
- const url = new URL(path, baseOrigin)
200
- if (url.origin !== baseOrigin) {
201
- throw new CliError(
202
- `Refusing to send request outside your Pipedrive company host ` +
203
- `(${baseOrigin}): ${url.origin}`,
204
- { exitCode: 78 },
205
- )
206
- }
207
- return url
208
- }
209
-
210
- function authHeaders() {
211
- return authMode === 'oauth'
212
- ? { authorization: `Bearer ${token}`, 'user-agent': userAgent }
213
- : { 'x-api-token': token, 'user-agent': userAgent }
214
- }
215
-
216
265
  /**
217
266
  * Download a binary resource (e.g. /api/v1/files/:id/download).
218
267
  * @param {string} path
219
268
  * @returns {Promise<{buffer: ArrayBuffer, contentType: string | null}>}
220
269
  */
221
270
  async function download(path) {
222
- const url = lockedUrl(path)
223
- const res = await fetch(url, {
224
- headers: authHeaders(),
225
- signal: AbortSignal.timeout(timeout),
226
- })
227
- debug('GET (binary) %s → %d', path, res.status)
228
- if (!res.ok) {
229
- throw ApiError.fromResponse(res.status, await res.text(), path)
230
- }
231
- return {
232
- buffer: await res.arrayBuffer(),
233
- contentType: res.headers.get('content-type'),
234
- }
271
+ return transport('GET', lockedUrl(path), { path, binary: true })
235
272
  }
236
273
 
237
274
  /**
@@ -240,24 +277,16 @@ export function createClient({
240
277
  * @param {{ file: { name: string, data: Buffer | Uint8Array }, fields?: Record<string, unknown> }} options
241
278
  */
242
279
  async function postMultipart(path, { file, fields = {} }) {
243
- const url = lockedUrl(path)
244
- const form = new FormData()
245
- form.set('file', new Blob([file.data]), file.name)
246
- for (const [k, v] of Object.entries(fields)) {
247
- if (v != null) form.set(k, String(v))
248
- }
249
- const res = await fetch(url, {
250
- method: 'POST',
251
- headers: authHeaders(), // fetch sets the multipart boundary itself
252
- body: form,
253
- signal: AbortSignal.timeout(timeout),
254
- })
255
- debug('POST (multipart) %s → %d', path, res.status)
256
- const text = await res.text()
257
- if (!res.ok) {
258
- throw ApiError.fromResponse(res.status, text, path)
280
+ // fetch sets the multipart boundary itself — no content-type override.
281
+ const makeBody = () => {
282
+ const form = new FormData()
283
+ form.set('file', new Blob([file.data]), file.name)
284
+ for (const [k, v] of Object.entries(fields)) {
285
+ if (v != null) form.set(k, String(v))
286
+ }
287
+ return form
259
288
  }
260
- return text ? JSON.parse(text) : null
289
+ return transport('POST', lockedUrl(path), { path, makeBody })
261
290
  }
262
291
 
263
292
  /**
@@ -267,26 +296,15 @@ export function createClient({
267
296
  * @param {Record<string, unknown>} fields Null/undefined values are omitted.
268
297
  */
269
298
  async function postForm(path, fields = {}) {
270
- const url = lockedUrl(path)
271
299
  const params = new URLSearchParams()
272
300
  for (const [k, v] of Object.entries(fields)) {
273
301
  if (v != null) params.set(k, String(v))
274
302
  }
275
- const res = await fetch(url, {
276
- method: 'POST',
277
- headers: {
278
- ...authHeaders(),
279
- 'content-type': 'application/x-www-form-urlencoded',
280
- },
281
- body: params.toString(),
282
- signal: AbortSignal.timeout(timeout),
303
+ return transport('POST', lockedUrl(path), {
304
+ path,
305
+ makeBody: () => params.toString(),
306
+ extraHeaders: { 'content-type': 'application/x-www-form-urlencoded' },
283
307
  })
284
- debug('POST (form) %s → %d', path, res.status)
285
- const text = await res.text()
286
- if (!res.ok) {
287
- throw ApiError.fromResponse(res.status, text, path)
288
- }
289
- return text ? JSON.parse(text) : null
290
308
  }
291
309
 
292
310
  return {