@giveitsmaller/sdk 0.2.3 → 0.4.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/dist/client.js CHANGED
@@ -1,45 +1,205 @@
1
1
  import { readFileSync, statSync } from 'node:fs';
2
2
  import { basename } from 'node:path';
3
- import { UploadResponseFromJSON, MultipartInitiateResponseFromJSON, MultipartCompleteResponseFromJSON, WorkflowCreateResponseFromJSON, WorkflowStatusResponseFromJSON, WorkflowDownloadResponseFromJSON, MetadataResponseFromJSON, OperationsSchemaResponseFromJSON, RetryResponseFromJSON, WorkflowStatus, } from '@giveitsmaller/contracts/openapi';
4
- import { GislApiError, GislError, GislTimeoutError, GislValidationError } from './errors.js';
3
+ import { AudioWatermarkDecodeRequestToJSON, AudioWatermarkDecodeResponseFromJSON, ExternalImportCreatedResponseFromJSON, ExternalImportRequestToJSON, LoginUser200ResponseDataFromJSON, CreditsBalanceResponseFromJSON, CreditsUsageResponseFromJSON, UploadResponseFromJSON, UploadProbeResponseFromJSON, MultipartInitiateResponseFromJSON, MultipartInitiateRequestMetadataHintToJSON, MultipartCompleteResponseFromJSON, MultipartCompleteRequestToJSON, WorkflowCancelResponseFromJSON, WorkflowCreateResponseFromJSON, WorkflowResumeResponseFromJSON, WorkflowStatusResponseFromJSON, WorkflowDownloadResponseFromJSON, MetadataResponseFromJSON, OperationsSchemaResponseFromJSON, RetryResponseFromJSON, WorkflowStatus, AuthErrorResponseFromJSON, AuthErrorType, BalanceExhaustedResponseFromJSON, BalanceExhaustedResponseRequiredActionEnum, FeatureNotAvailableResponseFromJSON, FeatureTierRestrictedResponseFromJSON, TierRestrictionKind, TierRestrictionResponseFromJSON, UserTier, WorkflowExpiredResponseFromJSON, UploadThresholdsSingleShotMaxBytesEnum, UploadThresholdsMultipartChunkSizeEnum, UploadThresholdsMultipartConcurrencyDefaultEnum, } from '@giveitsmaller/contracts/openapi';
4
+ import { GislAbortError, GislApiError, GislAuthError, GislBalanceExhaustedError, GislError, GislFeatureNotAvailableError, GislFeatureTierRestrictedError, GislTierRestrictedError, GislTimeoutError, GislValidationError, GislWorkflowExpiredError, } from './errors.js';
5
5
  import { parseSseStream } from './sse.js';
6
6
  const DEFAULT_TIMEOUT_MS = 30_000;
7
- const DEFAULT_MULTIPART_THRESHOLD = 10 * 1024 * 1024; // 10 MB
8
- const DEFAULT_MULTIPART_CONCURRENCY = 4;
7
+ // SDK-internal aliases derived from the contract-pinned UploadThresholds enums
8
+ // (compression_contracts/openapi schema `UploadThresholds`, ticket u0ar7Yye).
9
+ // `satisfies number` keeps the literal type so the drift guards below pin the
10
+ // expected value at compile time. Bumping any of these requires a contracts
11
+ // release that regenerates the corresponding *Enum, plus updating the literal
12
+ // in the matching `_AssertTrue<>` line.
13
+ const SINGLE_SHOT_MAX_BYTES = UploadThresholdsSingleShotMaxBytesEnum.NUMBER_10000000;
14
+ const MULTIPART_CHUNK_SIZE = UploadThresholdsMultipartChunkSizeEnum.NUMBER_5242880;
15
+ export const MULTIPART_CONCURRENCY_DEFAULT = UploadThresholdsMultipartConcurrencyDefaultEnum.NUMBER_4;
16
+ const DEFAULT_MULTIPART_MAX_ATTEMPTS = 3;
17
+ const DEFAULT_MULTIPART_RETRY_BASE_MS = 500;
9
18
  // Fixed per contract (compression_contracts/openapi/api.yaml:134). The server
10
19
  // uses the first chunk for MIME detection + throughput measurement and stores
11
20
  // it as S3 multipart part 1. Must NOT be derived from multipartThreshold —
12
21
  // that is the "use multipart above this size" routing threshold, a separate
13
22
  // concept. Conflating them caused the /api/uploads/multipart/initiate 413.
23
+ // TODO(58nBQLWQ): replace with UploadThresholdsMultipartFirstChunkSizeEnum
24
+ // once contracts ticket promotes this to a typed const (v2.3.1 follow-up).
14
25
  export const DEFAULT_MULTIPART_FIRST_CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB
15
26
  const DEFAULT_POLL_INTERVAL_MS = 2_000;
16
27
  const DEFAULT_POLL_TIMEOUT_MS = 300_000; // 5 min
28
+ // Statuses that waitForWorkflow() returns immediately on. Per ticket I24,
29
+ // `cancelled` and `expired` are terminal (a workflow cannot leave either
30
+ // state). `paused_insufficient_credits` is a soft-pause: not terminal, but
31
+ // polling blindly is the wrong behaviour because the workflow only resumes
32
+ // on caller action (top-up + resume). The SDK returns immediately so the
33
+ // caller can inspect `pausedDetail` and drive the resume flow.
17
34
  const TERMINAL_STATUSES = new Set([
18
35
  WorkflowStatus.completed,
19
36
  WorkflowStatus.failed,
20
37
  WorkflowStatus.partially_failed,
38
+ WorkflowStatus.cancelled,
39
+ WorkflowStatus.expired,
40
+ WorkflowStatus.paused_insufficient_credits,
21
41
  ]);
22
42
  function isValidationDetails(value) {
23
43
  return (Array.isArray(value) &&
44
+ value.length > 0 &&
24
45
  value.every((el) => typeof el === 'object' &&
25
46
  el !== null &&
26
- typeof el.field === 'string' &&
27
47
  typeof el.message === 'string'));
28
48
  }
49
+ // An abort may surface as DOMException (browser + modern Node), a plain Error
50
+ // subclass with name='AbortError', or (rarely) a plain object with that name
51
+ // on less conformant runtimes. Match any non-null thing exposing the name.
52
+ function isAbortError(err) {
53
+ return (err !== null &&
54
+ typeof err === 'object' &&
55
+ err.name === 'AbortError');
56
+ }
57
+ // Retryable S3 PUT response statuses: 429 throttling, 503 slow-down, and any
58
+ // other 5xx (502/504 are common transients behind CloudFront/S3). 4xx other
59
+ // than 429 (403 signed-URL expiry, 400 SignatureDoesNotMatch, etc.) are
60
+ // configuration / authority issues — retrying just delays the real failure.
61
+ function isRetryableStatus(status) {
62
+ return status === 429 || (status >= 500 && status <= 599);
63
+ }
64
+ // fetch surfaces network failures (DNS, TLS, TCP reset, mid-body disconnect)
65
+ // as TypeError. Abort surfaces as a DOMException with name='AbortError', not
66
+ // a TypeError, so a plain instanceof check is sufficient — abort is filtered
67
+ // before reaching here by the dedicated isAbortError guard in the catch.
68
+ function isRetryableNetworkError(err) {
69
+ return err instanceof TypeError;
70
+ }
71
+ // Full-jitter exponential backoff: delay = random(0, base * 2^attemptIndex).
72
+ // AWS SDK guidance for shared-throttling sources like S3 — keeps competing
73
+ // clients from synchronising their retries.
74
+ function fullJitterDelay(baseMs, attemptIndex) {
75
+ if (baseMs <= 0)
76
+ return 0;
77
+ const ceiling = baseMs * Math.pow(2, attemptIndex);
78
+ return Math.floor(Math.random() * ceiling);
79
+ }
80
+ // Cancel a Response body so undici (Node 18+ fetch) releases the underlying
81
+ // connection promptly instead of waiting for GC. We swallow any error: the
82
+ // retry loop is about to re-PUT the chunk; failing the cleanup must not
83
+ // shadow the real failure that triggered the retry.
84
+ async function drainResponseBody(response) {
85
+ try {
86
+ await response.body?.cancel();
87
+ }
88
+ catch {
89
+ /* ignore */
90
+ }
91
+ }
92
+ // Reject NaN/Infinity and floor at 1 attempt. A misconfigured 0/negative
93
+ // still attempts the PUT once (so callers see the underlying error rather
94
+ // than a silent zero-PUT no-op).
95
+ function sanitiseAttempts(value, fallback) {
96
+ if (value === undefined || !Number.isFinite(value))
97
+ return fallback;
98
+ return Math.max(1, Math.floor(value));
99
+ }
100
+ // Reject NaN/Infinity and clamp at 0. `0` is permitted so callers can opt out
101
+ // of backoff entirely (e.g. for fast-path tests); `Infinity` would otherwise
102
+ // stall the retry loop indefinitely on the very first backoff.
103
+ function sanitiseBaseMs(value, fallback) {
104
+ if (value === undefined || !Number.isFinite(value))
105
+ return fallback;
106
+ return Math.max(0, Math.floor(value));
107
+ }
108
+ // Reject NaN/Infinity and snap to fallback for any value below 1. Diverges
109
+ // from sanitiseAttempts (which floors at 1) because zero workers here is
110
+ // not a "fail-fast once" semantic — `Math.min(0, queue.length) = 0` at the
111
+ // worker fan-out site below produces zero S3 PUTs, so /multipart/complete
112
+ // is then called with an incomplete `parts` array (silent corruption).
113
+ // Garbage input almost certainly meant "use the default", not "send no parts".
114
+ function sanitiseConcurrency(value, fallback) {
115
+ if (value === undefined || !Number.isFinite(value))
116
+ return fallback;
117
+ const floored = Math.floor(value);
118
+ return floored < 1 ? fallback : floored;
119
+ }
120
+ // Cancellable sleep. Resolves after `ms` ms, rejects with GislAbortError if
121
+ // the caller's signal aborts, or resolves early (without throwing) if the
122
+ // internal `wakeSignal` fires — that path lets a sibling worker's terminal
123
+ // failure short-circuit a peer's backoff sleep without producing a spurious
124
+ // abort error in the peer's own throw stack.
125
+ function sleepWithEitherSignal(ms, abortSignal, wakeSignal) {
126
+ if (abortSignal?.aborted) {
127
+ return Promise.reject(new GislAbortError('Multipart upload aborted'));
128
+ }
129
+ if (wakeSignal.aborted || ms <= 0)
130
+ return Promise.resolve();
131
+ return new Promise((resolve, reject) => {
132
+ const cleanup = () => {
133
+ abortSignal?.removeEventListener('abort', onAbort);
134
+ wakeSignal.removeEventListener('abort', onWake);
135
+ };
136
+ const onAbort = () => {
137
+ clearTimeout(timer);
138
+ cleanup();
139
+ reject(new GislAbortError('Multipart upload aborted'));
140
+ };
141
+ const onWake = () => {
142
+ clearTimeout(timer);
143
+ cleanup();
144
+ resolve();
145
+ };
146
+ const timer = setTimeout(() => {
147
+ cleanup();
148
+ resolve();
149
+ }, ms);
150
+ abortSignal?.addEventListener('abort', onAbort, { once: true });
151
+ wakeSignal.addEventListener('abort', onWake, { once: true });
152
+ });
153
+ }
154
+ // Wire an optional external AbortSignal onto an internal per-request controller
155
+ // so either source trips the composed fetch. The `onExternalAbort` callback
156
+ // fires the moment the external signal aborts — callers use it to capture the
157
+ // temporal order of user-vs-timeout causes, so the tiebreak in request() does
158
+ // not rely on `signal.aborted` read at catch time (which flips true regardless
159
+ // of which cause actually fired first).
160
+ //
161
+ // Returns a teardown that removes the listener — must be called in a finally
162
+ // so long-lived user AbortControllers do not accumulate listeners across many
163
+ // uploads. Node 18+ compatible (no AbortSignal.any).
164
+ function bindAbortSignal(external, internal, onExternalAbort) {
165
+ if (!external)
166
+ return () => { };
167
+ if (external.aborted) {
168
+ onExternalAbort?.();
169
+ internal.abort();
170
+ return () => { };
171
+ }
172
+ const onAbort = () => {
173
+ onExternalAbort?.();
174
+ internal.abort();
175
+ };
176
+ external.addEventListener('abort', onAbort, { once: true });
177
+ return () => external.removeEventListener('abort', onAbort);
178
+ }
29
179
  export class GislClient {
30
180
  baseUrl;
31
181
  headers;
32
182
  timeoutMs;
33
183
  multipartThreshold;
34
184
  multipartConcurrency;
185
+ multipartMaxAttempts;
186
+ multipartRetryBaseMs;
187
+ useSessionCookie;
35
188
  constructor(config) {
36
189
  this.baseUrl = config.baseUrl.replace(/\/+$/, '');
37
190
  this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
191
+ this.useSessionCookie = config.useSessionCookie ?? false;
38
192
  // Floor the threshold at the first-chunk size: the multipart initiate
39
193
  // must always carry an 8MB chunk, so routing a sub-8MB file into the
40
194
  // multipart path would violate the contract.
41
- this.multipartThreshold = Math.max(config.multipartThreshold ?? DEFAULT_MULTIPART_THRESHOLD, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
42
- this.multipartConcurrency = config.multipartConcurrency ?? DEFAULT_MULTIPART_CONCURRENCY;
195
+ this.multipartThreshold = Math.max(config.multipartThreshold ?? SINGLE_SHOT_MAX_BYTES, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
196
+ this.multipartConcurrency = sanitiseConcurrency(config.multipartConcurrency, MULTIPART_CONCURRENCY_DEFAULT);
197
+ // Sanitise: reject NaN/Infinity (the former would cause `attempt < NaN`
198
+ // to be perpetually false, skipping every PUT; the latter would retry
199
+ // unboundedly). Floor at 1 so a misconfigured 0/negative still attempts
200
+ // once.
201
+ this.multipartMaxAttempts = sanitiseAttempts(config.multipartMaxAttempts, DEFAULT_MULTIPART_MAX_ATTEMPTS);
202
+ this.multipartRetryBaseMs = sanitiseBaseMs(config.multipartRetryBaseMs, DEFAULT_MULTIPART_RETRY_BASE_MS);
43
203
  this.headers = { ...config.headers };
44
204
  if (config.apiKey) {
45
205
  this.headers['Authorization'] = `Bearer ${config.apiKey}`;
@@ -49,8 +209,12 @@ export class GislClient {
49
209
  // Internal HTTP
50
210
  // -----------------------------------------------------------------------
51
211
  async request(method, path, opts = {}) {
212
+ // Fast-fail on a pre-aborted user signal before building the request.
213
+ if (opts.signal?.aborted) {
214
+ throw new GislAbortError(`Request to ${method} ${path} aborted`);
215
+ }
52
216
  const url = `${this.baseUrl}${path}`;
53
- const headers = { ...this.headers };
217
+ const headers = { ...this.headers, ...opts.headers };
54
218
  let body;
55
219
  if (opts.json !== false && opts.body && !(opts.body instanceof FormData)) {
56
220
  headers['Content-Type'] = 'application/json';
@@ -60,7 +224,22 @@ export class GislClient {
60
224
  body = opts.body;
61
225
  }
62
226
  const controller = new AbortController();
63
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
227
+ // Record the first cause that tripped the composed controller. Checking
228
+ // `opts.signal.aborted` alone at catch time is not sound: if the timer
229
+ // fires first and the user signal aborts microseconds later (before
230
+ // `catch` runs), that flag is also true — but the true cause was the
231
+ // timeout. Capturing which side fired first gives a deterministic
232
+ // classification regardless of scheduling.
233
+ let firstCause = null;
234
+ const timer = setTimeout(() => {
235
+ if (firstCause === null)
236
+ firstCause = 'timeout';
237
+ controller.abort();
238
+ }, this.timeoutMs);
239
+ const unbind = bindAbortSignal(opts.signal, controller, () => {
240
+ if (firstCause === null)
241
+ firstCause = 'user';
242
+ });
64
243
  let response;
65
244
  try {
66
245
  response = await fetch(url, {
@@ -68,20 +247,35 @@ export class GislClient {
68
247
  headers,
69
248
  body,
70
249
  signal: controller.signal,
250
+ // `credentials: 'include'` on every request when the consumer opts
251
+ // into cookie-based auth (Symfony session via /api/auth/login).
252
+ // No-op in Node (fetch ignores the field there); mandatory for
253
+ // cross-origin browser SPAs to send the session cookie.
254
+ ...(this.useSessionCookie ? { credentials: 'include' } : {}),
71
255
  });
72
256
  }
73
257
  catch (err) {
74
- if (err instanceof DOMException && err.name === 'AbortError') {
258
+ if (isAbortError(err)) {
259
+ if (firstCause === 'user') {
260
+ throw new GislAbortError(`Request to ${method} ${path} aborted`);
261
+ }
75
262
  throw new GislTimeoutError(`Request to ${method} ${path} timed out after ${this.timeoutMs}ms`);
76
263
  }
77
264
  throw err;
78
265
  }
79
266
  finally {
80
267
  clearTimeout(timer);
268
+ unbind();
81
269
  }
82
270
  if (opts.rawResponse) {
83
271
  return response;
84
272
  }
273
+ // 204 No Content — contracted success status for endpoints that return
274
+ // no body (e.g. POST /api/contact). Short-circuit before handleResponse
275
+ // so an empty body never trips the JSON parser.
276
+ if (response.status === 204) {
277
+ return undefined;
278
+ }
85
279
  return this.handleResponse(response, path, opts.deserialize);
86
280
  }
87
281
  async handleResponse(response, path, deserialize) {
@@ -93,6 +287,10 @@ export class GislClient {
93
287
  }
94
288
  return undefined;
95
289
  }
290
+ // Wire-side fields are snake_case (raw response.json() — never run through
291
+ // FromJSON helpers here). The structured-error subclasses receive a typed
292
+ // payload built via the per-envelope FromJSON helper, which handles the
293
+ // snake_case -> camelCase conversion for nested fields.
96
294
  let json;
97
295
  try {
98
296
  json = await response.json();
@@ -100,23 +298,92 @@ export class GislClient {
100
298
  catch {
101
299
  throw new GislApiError(response.status, 'Invalid JSON response', path);
102
300
  }
103
- // Schema endpoint returns raw JSON (no envelope)
104
- if (path === '/api/operations/schema') {
105
- if (!response.ok) {
106
- throw new GislApiError(response.status, json.error ?? 'Unknown error', path);
107
- }
108
- return deserialize ? deserialize(json) : json;
109
- }
110
301
  // Standard envelope: { success, data } or { success, error, details }
111
302
  if (!response.ok || json.success === false) {
303
+ // Localisation triple per ticket I26 — surfaced on every typed error so
304
+ // consumers can drive client-side i18n catalogs without unwrapping the
305
+ // typed payload. Field names are wire snake_case here; the typed payload
306
+ // (built via FromJSON below) carries camelCase copies.
307
+ const i18n = {
308
+ messageKey: json.message_key,
309
+ locale: json.locale,
310
+ messageParams: json.message_params,
311
+ };
312
+ // Validation-details branch first — preserve existing shape so callers
313
+ // matching on `instanceof GislValidationError` keep working.
112
314
  if (isValidationDetails(json.details)) {
113
- throw new GislValidationError(response.status, json.error ?? 'Validation error', json.details, path);
315
+ throw new GislValidationError(response.status, json.error ?? 'Validation error', json.details, path, i18n);
316
+ }
317
+ // Dispatch by (status, error_type) onto the structured envelope shapes
318
+ // emitted by the v2 contracts. Each branch builds the typed payload via
319
+ // the generated FromJSON helper so consumers reading e.g.
320
+ // `error.payload.errorType` see camelCase fields rather than the raw
321
+ // wire snake_case.
322
+ //
323
+ // Defense-in-depth: if a malformed wire envelope causes the FromJSON
324
+ // helper to throw or coerce a required field to a sentinel value
325
+ // (e.g. `expired_at` missing -> `new Date(undefined)` => Invalid Date),
326
+ // fall through to the base `GislApiError` rather than handing the
327
+ // caller silently-corrupted typed metadata.
328
+ const errorType = json.error_type;
329
+ const status = response.status;
330
+ const errorMessage = json.error ?? 'Unknown error';
331
+ // Build the typed payload via FromJSON, then validate that all
332
+ // required typed fields are well-formed. FromJSON does not throw on
333
+ // missing required fields — for example `workflow_expired` without
334
+ // `expired_at` produces `new Date(undefined)` => Invalid Date with
335
+ // `getTime() === NaN`. Without an explicit validity check the error
336
+ // would surface a silently-corrupted typed payload instead of falling
337
+ // through to the generic base class.
338
+ const tryThrowStructured = (construct, ErrorClass, validate) => {
339
+ let payload;
340
+ try {
341
+ payload = construct(json);
342
+ }
343
+ catch {
344
+ return undefined;
345
+ }
346
+ if (validate && !validate(payload)) {
347
+ return undefined;
348
+ }
349
+ throw new ErrorClass(status, errorMessage, payload, path, i18n);
350
+ };
351
+ const isValidDate = (d) => d instanceof Date && !Number.isNaN(d.getTime());
352
+ if (status === 401 || status === 403) {
353
+ if (errorType && this.isAuthErrorType(errorType)) {
354
+ tryThrowStructured(AuthErrorResponseFromJSON, GislAuthError, (p) => typeof p.errorType === 'string');
355
+ }
356
+ }
357
+ const isInEnum = (value, members) => typeof value === 'string' && Object.values(members).includes(value);
358
+ const isFeatureViolation = (v) => typeof v === 'object' && v !== null
359
+ && typeof v.feature === 'string';
360
+ if (status === 402 && errorType === 'balance_exhausted') {
361
+ tryThrowStructured(BalanceExhaustedResponseFromJSON, GislBalanceExhaustedError, (p) => isInEnum(p.requiredAction, BalanceExhaustedResponseRequiredActionEnum));
362
+ }
363
+ if (status === 403 && errorType === 'tier_restriction') {
364
+ tryThrowStructured(TierRestrictionResponseFromJSON, GislTierRestrictedError, (p) => isInEnum(p.restrictionKind, TierRestrictionKind)
365
+ && isInEnum(p.currentTier, UserTier));
366
+ }
367
+ if (status === 403 && errorType === 'feature_tier_restricted') {
368
+ tryThrowStructured(FeatureTierRestrictedResponseFromJSON, GislFeatureTierRestrictedError, (p) => Array.isArray(p.violations) && p.violations.every(isFeatureViolation));
369
+ }
370
+ if (status === 422 && errorType === 'feature_not_available') {
371
+ tryThrowStructured(FeatureNotAvailableResponseFromJSON, GislFeatureNotAvailableError, (p) => Array.isArray(p.violations) && p.violations.every(isFeatureViolation));
372
+ }
373
+ if (status === 422 && errorType === 'workflow_expired') {
374
+ tryThrowStructured(WorkflowExpiredResponseFromJSON, GislWorkflowExpiredError, (p) => isValidDate(p.expiredAt));
114
375
  }
115
- throw new GislApiError(response.status, json.error ?? 'Unknown error', path, json.details);
376
+ throw new GislApiError(status, errorMessage, path, json.details, { ...i18n, payload: json });
116
377
  }
117
378
  const data = json.data ?? json;
118
379
  return deserialize ? deserialize(data) : data;
119
380
  }
381
+ // Membership check for the AuthErrorType discriminator. Reads the generated
382
+ // enum object directly so a future contract addition lands here without a
383
+ // hand-edit. The bundle cost is one tiny `as const` literal map (8 entries).
384
+ isAuthErrorType(value) {
385
+ return Object.values(AuthErrorType).includes(value);
386
+ }
120
387
  // -----------------------------------------------------------------------
121
388
  // Upload
122
389
  // -----------------------------------------------------------------------
@@ -128,6 +395,11 @@ export class GislClient {
128
395
  * @param options Upload options including progress callback.
129
396
  */
130
397
  async uploadFile(file, options) {
398
+ // Pre-abort check: bail before statSync/readFileSync buffers the whole
399
+ // file into memory when the caller has already cancelled.
400
+ if (options?.signal?.aborted) {
401
+ throw new GislAbortError('Upload aborted before start');
402
+ }
131
403
  let blob;
132
404
  let fileName;
133
405
  let fileSize;
@@ -146,15 +418,16 @@ export class GislClient {
146
418
  if (fileSize > this.multipartThreshold) {
147
419
  return this.multipartUpload(blob, fileName, fileSize, options);
148
420
  }
149
- return this.singleUpload(blob, fileName);
421
+ return this.singleUpload(blob, fileName, options);
150
422
  }
151
- async singleUpload(blob, fileName) {
423
+ async singleUpload(blob, fileName, options) {
152
424
  const form = new FormData();
153
425
  form.append('file', blob, fileName);
154
426
  return this.request('POST', '/api/uploads', {
155
427
  body: form,
156
428
  json: false,
157
429
  deserialize: UploadResponseFromJSON,
430
+ signal: options?.signal,
158
431
  });
159
432
  }
160
433
  /**
@@ -176,10 +449,23 @@ export class GislClient {
176
449
  initiateForm.append('file', firstChunk, fileName);
177
450
  initiateForm.append('filename', fileName);
178
451
  initiateForm.append('total_size', totalSize.toString());
452
+ if (options?.metadataHint !== undefined) {
453
+ // Wire format: a single FormData field carrying the JSON-stringified
454
+ // hint object. Single-shot uploads do not accept this field — see
455
+ // singleUpload() which silently ignores `options.metadataHint`.
456
+ // Route through the generated ToJSON helper so the wire form is the
457
+ // contract-pinned snake_case shape (`duration_seconds`, `width`,
458
+ // `height`). Stringify-ing the camelCase TS object directly would
459
+ // emit `durationSeconds` and the server would silently drop it,
460
+ // defeating the hint's primary use (long-form pre-classification when
461
+ // the first-chunk probe lacks container metadata).
462
+ initiateForm.append('metadata_hint', JSON.stringify(MultipartInitiateRequestMetadataHintToJSON(options.metadataHint)));
463
+ }
179
464
  const initResponse = await this.request('POST', '/api/uploads/multipart/initiate', {
180
465
  body: initiateForm,
181
466
  json: false,
182
467
  deserialize: MultipartInitiateResponseFromJSON,
468
+ signal: options?.signal,
183
469
  });
184
470
  let uploadedBytes = firstChunkSize;
185
471
  options?.onProgress?.(uploadedBytes, totalSize);
@@ -187,44 +473,161 @@ export class GislClient {
187
473
  const etags = [];
188
474
  const presignedUrls = initResponse.presignedUrls;
189
475
  const chunkSize = initResponse.recommendedChunkSize;
476
+ // Internal abort signal that workers use to short-circuit each others'
477
+ // backoff sleeps. When any worker hits a terminal failure it aborts this
478
+ // controller, which races the caller's signal inside sleepWithSignal so
479
+ // sleeping siblings stop waiting for their timer to expire.
480
+ const failureController = new AbortController();
481
+ // Single PUT attempt. Returns a structured outcome instead of throwing
482
+ // for retryable/non-retryable distinctions, so the caller can decide
483
+ // whether to loop without conflating user-callback errors with
484
+ // network-layer retries (codex review).
485
+ const attemptPut = async (part, chunk, contentLength) => {
486
+ let s3Response;
487
+ try {
488
+ s3Response = await fetch(part.url, {
489
+ method: 'PUT',
490
+ body: chunk,
491
+ headers: { 'Content-Length': contentLength.toString() },
492
+ signal: options?.signal,
493
+ });
494
+ }
495
+ catch (err) {
496
+ if (isAbortError(err) && options?.signal?.aborted) {
497
+ return {
498
+ kind: 'fatal',
499
+ err: new GislAbortError(`S3 part ${part.partNumber} upload aborted`),
500
+ };
501
+ }
502
+ if (isRetryableNetworkError(err)) {
503
+ return { kind: 'retryable', lastErr: err };
504
+ }
505
+ return { kind: 'fatal', err };
506
+ }
507
+ if (s3Response.ok) {
508
+ const etag = s3Response.headers.get('etag');
509
+ if (!etag) {
510
+ // Drain the body even though we're failing fast — keeps the
511
+ // connection released eagerly.
512
+ await drainResponseBody(s3Response);
513
+ return {
514
+ kind: 'fatal',
515
+ err: new GislError(`S3 response missing ETag for part ${part.partNumber}`),
516
+ };
517
+ }
518
+ return { kind: 'ok', etag };
519
+ }
520
+ // Non-OK: drain the body in BOTH branches before deciding. Undici
521
+ // holds the connection open until the body is consumed regardless of
522
+ // whether we retry.
523
+ await drainResponseBody(s3Response);
524
+ if (!isRetryableStatus(s3Response.status)) {
525
+ return {
526
+ kind: 'fatal',
527
+ err: new GislError(`S3 chunk upload failed for part ${part.partNumber}: ${s3Response.status}`),
528
+ };
529
+ }
530
+ return {
531
+ kind: 'retryable',
532
+ lastErr: new GislError(`S3 chunk upload failed for part ${part.partNumber}: ${s3Response.status}`),
533
+ };
534
+ };
190
535
  const uploadChunk = async (index) => {
191
536
  const part = presignedUrls[index];
192
537
  const start = firstChunkSize + index * chunkSize;
193
538
  const end = Math.min(start + chunkSize, totalSize);
539
+ // Blob.slice() returns a new Blob view; the underlying bytes are
540
+ // immutable so the same `chunk` may be re-sent across retry attempts.
541
+ // S3 multipart parts are idempotent by partNumber — a re-PUT overwrites,
542
+ // there is no duplicate-data risk.
194
543
  const chunk = blob.slice(start, end);
195
- const s3Response = await fetch(part.url, {
196
- method: 'PUT',
197
- body: chunk,
198
- headers: { 'Content-Length': (end - start).toString() },
199
- });
200
- if (!s3Response.ok) {
201
- throw new GislError(`S3 chunk upload failed for part ${part.partNumber}: ${s3Response.status}`);
202
- }
203
- const etag = s3Response.headers.get('etag');
204
- if (!etag) {
205
- throw new GislError(`S3 response missing ETag for part ${part.partNumber}`);
544
+ const contentLength = end - start;
545
+ let lastErr = null;
546
+ for (let attempt = 0; attempt < this.multipartMaxAttempts; attempt++) {
547
+ if (options?.signal?.aborted) {
548
+ throw new GislAbortError(`S3 part ${part.partNumber} upload aborted`);
549
+ }
550
+ if (failureController.signal.aborted) {
551
+ // Sibling worker hit a terminal failure: bail before dispatching a
552
+ // wasted PUT. The thrown error is swallowed by the outer worker
553
+ // loop — Promise.all has already settled with the first failure.
554
+ throw new GislError(`S3 part ${part.partNumber} upload abandoned after sibling worker failure`);
555
+ }
556
+ const outcome = await attemptPut(part, chunk, contentLength);
557
+ if (outcome.kind === 'ok') {
558
+ // Apply progress side effects OUTSIDE the retry-scoped path so a
559
+ // user-callback throw does not trigger a duplicate PUT (codex
560
+ // review: retrying after a successful PUT would double-record the
561
+ // ETag and re-upload an already accepted part).
562
+ etags.push({ partNumber: part.partNumber, etag: outcome.etag });
563
+ uploadedBytes += contentLength;
564
+ options?.onProgress?.(uploadedBytes, totalSize);
565
+ return;
566
+ }
567
+ if (outcome.kind === 'fatal') {
568
+ throw outcome.err;
569
+ }
570
+ lastErr = outcome.lastErr;
571
+ if (attempt + 1 >= this.multipartMaxAttempts)
572
+ break;
573
+ const delay = fullJitterDelay(this.multipartRetryBaseMs, attempt);
574
+ // Race the caller's signal AND the sibling-failure signal so a
575
+ // worker that fails terminally wakes its sleeping peers instead of
576
+ // forcing them to wait out their backoff timer.
577
+ await sleepWithEitherSignal(delay, options?.signal, failureController.signal);
206
578
  }
207
- etags.push({ part_number: part.partNumber, etag });
208
- uploadedBytes += end - start;
209
- options?.onProgress?.(uploadedBytes, totalSize);
579
+ throw new GislError(`S3 chunk upload failed for part ${part.partNumber} after ${this.multipartMaxAttempts} attempts: ` +
580
+ (lastErr instanceof Error ? lastErr.message : String(lastErr)));
210
581
  };
211
- // Upload with concurrency limit
582
+ // Upload with concurrency limit. Workers check the signal before pulling
583
+ // the next queue item so a mid-upload abort drains fast without
584
+ // dispatching new chunks. Chunks already in-flight are cancelled via the
585
+ // composed signal passed to fetch above; sleeping siblings are woken via
586
+ // the failureController set below.
212
587
  const queue = [...presignedUrls.keys()];
213
588
  const workers = Array.from({ length: Math.min(this.multipartConcurrency, queue.length) }, async () => {
214
- while (queue.length > 0) {
589
+ while (queue.length > 0 && !failureController.signal.aborted) {
590
+ if (options?.signal?.aborted) {
591
+ throw new GislAbortError('Multipart upload aborted');
592
+ }
215
593
  const index = queue.shift();
216
- await uploadChunk(index);
594
+ try {
595
+ await uploadChunk(index);
596
+ }
597
+ catch (err) {
598
+ // Wake any sibling currently in a backoff sleep, and prevent
599
+ // siblings from picking up further queue items.
600
+ failureController.abort();
601
+ throw err;
602
+ }
217
603
  }
218
604
  });
219
605
  await Promise.all(workers);
220
606
  // Step 3: Complete multipart upload.
221
- etags.sort((a, b) => a.part_number - b.part_number);
607
+ // Build a typed MultipartCompleteRequest and serialise via the generated
608
+ // ToJSON helper — tsc now catches any field-name drift between the SDK
609
+ // and the OpenAPI spec (see contract-drift-fields.test.ts describe 'c').
610
+ etags.sort((a, b) => a.partNumber - b.partNumber);
611
+ const completeRequest = {
612
+ uploadId: initResponse.uploadId,
613
+ parts: etags,
614
+ };
615
+ // MultipartCompleteRequestToJSON's declared return type is the camelCase
616
+ // `MultipartCompleteRequest` interface, but at runtime it returns the
617
+ // snake_case wire object — an openapi-generator v7 quirk. The drift gate
618
+ // for field names lives at `completeRequest: MultipartCompleteRequest`
619
+ // above; the local wire type + runtime sanity check below guard against
620
+ // the remaining hypothetical: a future generator version emitting a
621
+ // different shape without the declared type catching it.
622
+ const wireCompleteBody = MultipartCompleteRequestToJSON(completeRequest);
623
+ if (typeof wireCompleteBody?.upload_id !== 'string' ||
624
+ !Array.isArray(wireCompleteBody?.parts)) {
625
+ throw new GislError('MultipartCompleteRequestToJSON returned an unexpected shape — generator output may have changed.');
626
+ }
222
627
  const completeResp = await this.request('POST', '/api/uploads/multipart/complete', {
223
- body: {
224
- upload_id: initResponse.uploadId,
225
- parts: etags,
226
- },
628
+ body: wireCompleteBody,
227
629
  deserialize: MultipartCompleteResponseFromJSON,
630
+ signal: options?.signal,
228
631
  });
229
632
  // Defensive: the status enum currently has only 'completed', but guard
230
633
  // against future expansion so an unexpected terminal state doesn't pass
@@ -237,6 +640,11 @@ export class GislClient {
237
640
  originalName: fileName,
238
641
  mimeType: initResponse.mimeType,
239
642
  sizeBytes: blob.size,
643
+ // Preserved from the initiate response: v2 contract makes
644
+ // `constraintsApplied` a REQUIRED field on UploadResponse, and the
645
+ // multipart/complete endpoint does not re-emit it. The first-chunk probe
646
+ // result on the initiate envelope is the authoritative source.
647
+ constraintsApplied: initResponse.constraintsApplied,
240
648
  };
241
649
  }
242
650
  // -----------------------------------------------------------------------
@@ -278,6 +686,49 @@ export class GislClient {
278
686
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
279
687
  }
280
688
  }
689
+ /**
690
+ * Cancel a workflow. Idempotent — cancelling an already-cancelled
691
+ * workflow returns 200 with the same shape (and the original
692
+ * `cancelledAt`). Cancelling a `completed` / `failed` /
693
+ * `partially_failed` / `expired` workflow returns 409.
694
+ *
695
+ * The response's `billingEffect` field tells the caller what
696
+ * happened to outstanding reservations:
697
+ * - `unspent_reservation_released` — workflow was active or paused
698
+ * and the unspent portion of the reservation has been refunded.
699
+ * The refund appears as a separate `CreditTransaction` with
700
+ * `type: refund`.
701
+ * - `none` — no refund (all reserved credits were already consumed
702
+ * by completed jobs, or this is an idempotent re-cancel).
703
+ *
704
+ * In-flight operations may continue running briefly after the
705
+ * cancel response while their Lambda processes terminate; the
706
+ * response is the binding "no further reservations will be made"
707
+ * signal.
708
+ */
709
+ async cancelWorkflow(workflowId) {
710
+ return this.request('POST', `/api/workflows/${encodeURIComponent(workflowId)}/cancel`, {
711
+ deserialize: WorkflowCancelResponseFromJSON,
712
+ });
713
+ }
714
+ /**
715
+ * Resume a workflow that is in `paused_insufficient_credits`.
716
+ *
717
+ * Resume succeeds only when `availableCredits` covers the next
718
+ * reservation. If the balance is still insufficient, throws
719
+ * `GislBalanceExhaustedError` (402, same envelope as the workflow-
720
+ * create 402 path) and the workflow stays paused. If the workflow
721
+ * is past its `expiresAt` (default 7-day TTL from `pausedAt`),
722
+ * throws `GislWorkflowExpiredError` (422) and the workflow has
723
+ * transitioned to `expired` — callers cannot un-expire a workflow.
724
+ * Resuming a workflow that is not in `paused_insufficient_credits`
725
+ * is a 409 (no-op).
726
+ */
727
+ async resumeWorkflow(workflowId) {
728
+ return this.request('POST', `/api/workflows/${encodeURIComponent(workflowId)}/resume`, {
729
+ deserialize: WorkflowResumeResponseFromJSON,
730
+ });
731
+ }
281
732
  /**
282
733
  * Get download URLs for a completed workflow.
283
734
  */
@@ -313,12 +764,58 @@ export class GislClient {
313
764
  // -----------------------------------------------------------------------
314
765
  /**
315
766
  * Get the operations schema (available types, options, constraints).
316
- * This endpoint returns raw JSON (no envelope) and is CDN-cacheable.
767
+ *
768
+ * Returns raw JSON (no envelope). The response is **per-tier private**
769
+ * (cache key includes the caller's `user_tier`); CDN-style public
770
+ * caching is not used. Pass `ifNoneMatch` / `ifModifiedSince` from a
771
+ * previous response to revalidate — a 304 surfaces as
772
+ * `{ notModified: true, etag, lastModified }` so callers can keep
773
+ * using their cached copy.
317
774
  */
318
- async getSchema() {
319
- return this.request('GET', '/api/operations/schema', {
320
- deserialize: OperationsSchemaResponseFromJSON,
775
+ async getSchema(options = {}) {
776
+ const params = new URLSearchParams();
777
+ if (options.mimeType !== undefined)
778
+ params.set('mime_type', options.mimeType);
779
+ if (options.operation !== undefined)
780
+ params.set('operation', options.operation);
781
+ const query = params.toString();
782
+ // The contract-drift test (tests/unit/contract-drift.test.ts) scans this
783
+ // file for path literals via a regex that picks up both single-quoted
784
+ // strings AND backtick templates. Embedding the querystring in a single
785
+ // template would normalise to a path-with-querystring that no contract
786
+ // path matches. Compose with concatenation so only the bare path appears
787
+ // as a literal.
788
+ const path = '/api/operations/schema' + (query ? '?' + query : '');
789
+ const headers = {};
790
+ if (options.ifNoneMatch !== undefined)
791
+ headers['If-None-Match'] = options.ifNoneMatch;
792
+ if (options.ifModifiedSince !== undefined)
793
+ headers['If-Modified-Since'] = options.ifModifiedSince;
794
+ const response = await this.request('GET', path, {
795
+ rawResponse: true,
796
+ signal: options.signal,
797
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
321
798
  });
799
+ const etag = response.headers.get('etag') ?? undefined;
800
+ const lastModified = response.headers.get('last-modified') ?? undefined;
801
+ if (response.status === 304) {
802
+ return { notModified: true, etag, lastModified };
803
+ }
804
+ if (!response.ok) {
805
+ let errorMessage = 'Unknown error';
806
+ try {
807
+ const errJson = (await response.json());
808
+ if (errJson.error)
809
+ errorMessage = errJson.error;
810
+ }
811
+ catch {
812
+ // Non-JSON body — keep generic message.
813
+ }
814
+ throw new GislApiError(response.status, errorMessage, path);
815
+ }
816
+ const raw = await response.json();
817
+ const data = OperationsSchemaResponseFromJSON(raw);
818
+ return { notModified: false, data, etag, lastModified };
322
819
  }
323
820
  /**
324
821
  * Retry a failed operation.
@@ -328,4 +825,213 @@ export class GislClient {
328
825
  deserialize: RetryResponseFromJSON,
329
826
  });
330
827
  }
828
+ // -----------------------------------------------------------------------
829
+ // Contact
830
+ // -----------------------------------------------------------------------
831
+ /**
832
+ * Submit a contact-form message. The endpoint returns 204 No Content on
833
+ * success, so this method resolves to `void`.
834
+ *
835
+ * Validation errors (e.g. missing `email`, non-empty honeypot `website`)
836
+ * surface as `GislValidationError` from the standard error envelope.
837
+ */
838
+ async submitContact(payload) {
839
+ await this.request('POST', '/api/contact', {
840
+ body: payload,
841
+ });
842
+ }
843
+ // -----------------------------------------------------------------------
844
+ // Credits / billing
845
+ // -----------------------------------------------------------------------
846
+ /**
847
+ * Get a snapshot of the caller's current credit position. The canonical
848
+ * billing-state surface — `BalanceExhaustedResponse` (402) on workflow
849
+ * creation includes pre-error counters for context, but UIs should drive
850
+ * spend-now affordances and tier-upgrade prompts off this endpoint, not
851
+ * off the error envelope.
852
+ */
853
+ async getCreditsBalance() {
854
+ return this.request('GET', '/api/v2/credits/balance', {
855
+ deserialize: CreditsBalanceResponseFromJSON,
856
+ });
857
+ }
858
+ // -----------------------------------------------------------------------
859
+ // Auth
860
+ // -----------------------------------------------------------------------
861
+ /**
862
+ * Authenticate with email/password. On success the server issues a
863
+ * session cookie via `Set-Cookie`; subsequent requests authenticate
864
+ * via that cookie when the client is configured with
865
+ * `useSessionCookie: true`.
866
+ *
867
+ * Failure modes per ticket FX6mbTJD:
868
+ * - **401** `invalid_credentials` (collapsed with unverified
869
+ * accounts for anti-enumeration) → `GislAuthError`.
870
+ * - **403** account-state failures (`account_locked`,
871
+ * `account_disabled`, `account_deleted`,
872
+ * `account_deletion_expired`) → `GislAuthError`.
873
+ * - **429** infrastructure rate-limit → `GislApiError` with
874
+ * the `Retry-After` header echoed on the response.
875
+ *
876
+ * Node session persistence (cookie-jar across processes) is out of
877
+ * scope — this method only touches the request side.
878
+ */
879
+ async login(credentials) {
880
+ return this.request('POST', '/api/auth/login', {
881
+ body: credentials,
882
+ deserialize: LoginUser200ResponseDataFromJSON,
883
+ });
884
+ }
885
+ /**
886
+ * Invalidate the current session.
887
+ *
888
+ * Idempotent: calling logout without an active session returns 401,
889
+ * but the SDK collapses both 200 and 401 into a single "logged out"
890
+ * outcome — `logout()` resolves to `void` in either case so caller
891
+ * cleanup code does not need to special-case the not-currently-
892
+ * authenticated path. Other errors (e.g. 500, network failures)
893
+ * still throw.
894
+ */
895
+ async logout() {
896
+ try {
897
+ await this.request('POST', '/api/auth/logout', {});
898
+ }
899
+ catch (err) {
900
+ // Treat 401 as success (already logged out — idempotent per
901
+ // contract). Logout 401 is a bare ErrorEnvelope with no
902
+ // `error_type`, so it surfaces as the base GislApiError rather
903
+ // than the typed GislAuthError — match on the status code to
904
+ // capture both shapes.
905
+ if (err instanceof GislApiError && err.statusCode === 401) {
906
+ return;
907
+ }
908
+ throw err;
909
+ }
910
+ }
911
+ // -----------------------------------------------------------------------
912
+ // External imports
913
+ // -----------------------------------------------------------------------
914
+ /**
915
+ * Register a one-shot bearer URL (S3 presigned, GCS signed, Azure
916
+ * SAS, Dropbox shared link, public HTTPS) and receive an opaque
917
+ * `externalSourceId` handle. Subsequent workflows reference the
918
+ * handle via `WorkflowSource` of `type: external_import` —
919
+ * compose with the [`externalImportSource()`](./types.ts) factory.
920
+ *
921
+ * Per ADR-0005 §"SSRF posture": the server validates 8 rules at
922
+ * registration time AND again at fetch time. HTTPS-only;
923
+ * private/loopback/cloud-metadata IPs are rejected (403). The
924
+ * original URL + password are encrypted at rest and never
925
+ * returned in any response.
926
+ *
927
+ * Currently `availability: planned` — the runtime endpoint returns
928
+ * 422 `feature_not_available` (or 404, per the cross-repo rollout)
929
+ * until the external-import infrastructure ships. The method
930
+ * exists today so consumers can write the integration ahead of
931
+ * time.
932
+ */
933
+ async createExternalImport(payload) {
934
+ return this.request('POST', '/api/external-imports', {
935
+ body: ExternalImportRequestToJSON(payload),
936
+ deserialize: ExternalImportCreatedResponseFromJSON,
937
+ });
938
+ }
939
+ // -----------------------------------------------------------------------
940
+ // Audio watermark
941
+ // -----------------------------------------------------------------------
942
+ /**
943
+ * Decode a previously-embedded steganographic audio watermark
944
+ * (per ticket I20). Pairs with the `audio_watermark` operation —
945
+ * the operation embeds; this endpoint decodes.
946
+ *
947
+ * **Enterprise tier only.** Free / pro callers receive
948
+ * `GislFeatureTierRestrictedError` (403).
949
+ *
950
+ * **Own watermarks only.** The decoder will refuse to extract from
951
+ * media the caller did not mark themselves — mismatches return 404
952
+ * (rather than leaking that *some* watermark was detected).
953
+ *
954
+ * Currently `availability: planned` — calls return
955
+ * `GislFeatureNotAvailableError` (422) until the cross-repo Lambda
956
+ * support ships. Decode requests are rate-limited independently
957
+ * from workflow-create.
958
+ */
959
+ async decodeAudioWatermark(payload) {
960
+ // The generated request type is camelCase; convert to snake_case wire
961
+ // shape before sending. Mirrors the multipart complete pattern.
962
+ return this.request('POST', '/api/audio-watermark/decode', {
963
+ body: AudioWatermarkDecodeRequestToJSON(payload),
964
+ deserialize: AudioWatermarkDecodeResponseFromJSON,
965
+ });
966
+ }
967
+ // -----------------------------------------------------------------------
968
+ // Upload probe / preflight
969
+ // -----------------------------------------------------------------------
970
+ /**
971
+ * Probe an uploaded file for workflow-readiness — detects corruption,
972
+ * unsupported codecs, and pre-assigns the processing class the server
973
+ * would route the file to. Designed for the long-form merge edge case
974
+ * where a single bad input would fail the whole workflow.
975
+ *
976
+ * Currently `availability: planned` — calls return
977
+ * `GislFeatureNotAvailableError` (422) until the cross-repo Lambda
978
+ * support ships. Idempotent: probing the same `fileId` twice returns
979
+ * the cached result.
980
+ */
981
+ async probeUpload(fileId) {
982
+ return this.request('POST', `/api/uploads/${encodeURIComponent(fileId)}/probe`, {
983
+ deserialize: UploadProbeResponseFromJSON,
984
+ });
985
+ }
986
+ /**
987
+ * Probe N uploaded files in parallel and partition the results by
988
+ * outcome. Returns `{ ok, rejected, errors }` so the caller can
989
+ * cleanly drop bad clips before submitting a long-form merge
990
+ * workflow. Probe-call failures (including the
991
+ * `feature_not_available` 422 returned while the endpoint is
992
+ * `availability: planned`) land in `errors` rather than throwing,
993
+ * so a partially-successful batch still yields useful aggregation.
994
+ */
995
+ async preflightClips(fileIds) {
996
+ const settled = await Promise.allSettled(fileIds.map((fileId) => this.probeUpload(fileId)));
997
+ const ok = [];
998
+ const rejected = [];
999
+ const errors = [];
1000
+ for (let i = 0; i < settled.length; i++) {
1001
+ const result = settled[i];
1002
+ const fileId = fileIds[i];
1003
+ if (result.status === 'fulfilled') {
1004
+ if (result.value.probeStatus === 'ok') {
1005
+ ok.push(result.value);
1006
+ }
1007
+ else {
1008
+ rejected.push(result.value);
1009
+ }
1010
+ }
1011
+ else {
1012
+ errors.push({ fileId, error: result.reason });
1013
+ }
1014
+ }
1015
+ return { ok, rejected, errors };
1016
+ }
1017
+ /**
1018
+ * Get a paginated page of credit transaction history for the caller.
1019
+ * Server defaults: `limit=20`, `offset=0`. Most-recent-first.
1020
+ */
1021
+ async getCreditsUsage(options = {}) {
1022
+ const params = new URLSearchParams();
1023
+ if (options.limit !== undefined)
1024
+ params.set('limit', String(options.limit));
1025
+ if (options.offset !== undefined)
1026
+ params.set('offset', String(options.offset));
1027
+ const query = params.toString();
1028
+ // String concatenation (not template) so the contract-drift path scanner
1029
+ // picks up the literal path. See getSchema for the same pattern.
1030
+ const path = query.length > 0
1031
+ ? '/api/v2/credits/usage' + '?' + query
1032
+ : '/api/v2/credits/usage';
1033
+ return this.request('GET', path, {
1034
+ deserialize: CreditsUsageResponseFromJSON,
1035
+ });
1036
+ }
331
1037
  }