@giveitsmaller/sdk 0.2.3 → 0.6.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,295 @@
1
- import { readFileSync, statSync } from 'node:fs';
1
+ import { open, stat } from 'node:fs/promises';
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, UploadSizeExceedsTierResponseFromJSON, UploadDurationExceedsTierResponseFromJSON, UploadConstraintsAppliedProcessingClassPreAssignmentEnum, UploadThresholdsSingleShotMaxBytesEnum, UploadThresholdsMultipartChunkSizeEnum, UploadThresholdsMultipartConcurrencyDefaultEnum, } from '@giveitsmaller/contracts/openapi';
4
+ import { GislAbortError, GislApiError, GislAuthError, GislBalanceExhaustedError, GislError, GislFeatureNotAvailableError, GislFeatureTierRestrictedError, GislMultipartPartCountError, GislMultipartPartError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTierRestrictedError, GislTimeoutError, GislUploadCapExceededError, 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_16777216;
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
26
+ // The ~2 GB wall on a single Node file read is NOT a Buffer-size limit
27
+ // (modern 64-bit `buffer.constants.MAX_LENGTH` is ~8 PiB). It is libuv's
28
+ // hard-coded INT32_MAX (2 147 483 647) ceiling on one `uv_fs_read` — some
29
+ // platforms reject I/O larger than INT32_MAX bytes per call, so libuv caps
30
+ // every read at it (github.com/nodejs/node/issues/55864). The streaming
31
+ // upload path never approaches this (server chunk size is bounded to
32
+ // <=100 MiB and the first chunk is fixed 8 MiB), but `fileByteSource`
33
+ // asserts it per read so any future caller that requests an oversized range
34
+ // fails loudly here instead of getting a silently short read from libuv.
35
+ const LIBUV_MAX_SINGLE_READ_BYTES = 0x7fffffff; // INT32_MAX
36
+ // S3 hard limit: a multipart upload may have at most 10 000 parts. The
37
+ // server computes the part plan and returns `total_parts`; the SDK trusts
38
+ // that value (Model A) but guards the ceiling so an out-of-contract server
39
+ // response or a chunk-size regression surfaces as a typed error rather than
40
+ // a doomed run of presigned PUTs ending in a rejected /multipart/complete.
41
+ const S3_MAX_MULTIPART_PARTS = 10_000;
42
+ // Contract bound on `MultipartInitiateResponse.recommended_chunk_size`
43
+ // (compression_contracts/openapi api.yaml — `maximum: 104857600`). The
44
+ // minimum is `multipart_chunk_size` (== MULTIPART_CHUNK_SIZE, drift-guarded
45
+ // above). The generated TS `FromJSON` does NO runtime validation (unlike the
46
+ // strict PHP generated model, which rejects out-of-range values at
47
+ // deserialize), so the TS SDK must enforce this range itself — otherwise a
48
+ // malformed/hostile server `recommended_chunk_size` would pass the
49
+ // part-count guard and drive `fileByteSource` into an unbounded
50
+ // `Buffer.allocUnsafe(length)` (the exact memory-blowup class this SDK
51
+ // exists to prevent). codex review (high).
52
+ const RECOMMENDED_CHUNK_SIZE_MAX_BYTES = 104_857_600; // 100 MiB
15
53
  const DEFAULT_POLL_INTERVAL_MS = 2_000;
16
54
  const DEFAULT_POLL_TIMEOUT_MS = 300_000; // 5 min
55
+ // Statuses that waitForWorkflow() returns immediately on. Per ticket I24,
56
+ // `cancelled` and `expired` are terminal (a workflow cannot leave either
57
+ // state). `paused_insufficient_credits` is a soft-pause: not terminal, but
58
+ // polling blindly is the wrong behaviour because the workflow only resumes
59
+ // on caller action (top-up + resume). The SDK returns immediately so the
60
+ // caller can inspect `pausedDetail` and drive the resume flow.
17
61
  const TERMINAL_STATUSES = new Set([
18
62
  WorkflowStatus.completed,
19
63
  WorkflowStatus.failed,
20
64
  WorkflowStatus.partially_failed,
65
+ WorkflowStatus.cancelled,
66
+ WorkflowStatus.expired,
67
+ WorkflowStatus.paused_insufficient_credits,
21
68
  ]);
22
69
  function isValidationDetails(value) {
23
70
  return (Array.isArray(value) &&
71
+ value.length > 0 &&
24
72
  value.every((el) => typeof el === 'object' &&
25
73
  el !== null &&
26
- typeof el.field === 'string' &&
27
74
  typeof el.message === 'string'));
28
75
  }
76
+ // An abort may surface as DOMException (browser + modern Node), a plain Error
77
+ // subclass with name='AbortError', or (rarely) a plain object with that name
78
+ // on less conformant runtimes. Match any non-null thing exposing the name.
79
+ function isAbortError(err) {
80
+ return (err !== null &&
81
+ typeof err === 'object' &&
82
+ err.name === 'AbortError');
83
+ }
84
+ // Retryable S3 PUT response statuses: 429 throttling, 503 slow-down, and any
85
+ // other 5xx (502/504 are common transients behind CloudFront/S3). 4xx other
86
+ // than 429 (403 signed-URL expiry, 400 SignatureDoesNotMatch, etc.) are
87
+ // configuration / authority issues — retrying just delays the real failure.
88
+ function isRetryableStatus(status) {
89
+ return status === 429 || (status >= 500 && status <= 599);
90
+ }
91
+ // fetch surfaces network failures (DNS, TLS, TCP reset, mid-body disconnect)
92
+ // as TypeError. Abort surfaces as a DOMException with name='AbortError', not
93
+ // a TypeError, so a plain instanceof check is sufficient — abort is filtered
94
+ // before reaching here by the dedicated isAbortError guard in the catch.
95
+ function isRetryableNetworkError(err) {
96
+ return err instanceof TypeError;
97
+ }
98
+ // Full-jitter exponential backoff: delay = random(0, base * 2^attemptIndex).
99
+ // AWS SDK guidance for shared-throttling sources like S3 — keeps competing
100
+ // clients from synchronising their retries.
101
+ function fullJitterDelay(baseMs, attemptIndex) {
102
+ if (baseMs <= 0)
103
+ return 0;
104
+ const ceiling = baseMs * Math.pow(2, attemptIndex);
105
+ return Math.floor(Math.random() * ceiling);
106
+ }
107
+ // Cancel a Response body so undici (Node 18+ fetch) releases the underlying
108
+ // connection promptly instead of waiting for GC. We swallow any error: the
109
+ // retry loop is about to re-PUT the chunk; failing the cleanup must not
110
+ // shadow the real failure that triggered the retry.
111
+ async function drainResponseBody(response) {
112
+ try {
113
+ await response.body?.cancel();
114
+ }
115
+ catch {
116
+ /* ignore */
117
+ }
118
+ }
119
+ // Reject NaN/Infinity and floor at 1 attempt. A misconfigured 0/negative
120
+ // still attempts the PUT once (so callers see the underlying error rather
121
+ // than a silent zero-PUT no-op).
122
+ function sanitiseAttempts(value, fallback) {
123
+ if (value === undefined || !Number.isFinite(value))
124
+ return fallback;
125
+ return Math.max(1, Math.floor(value));
126
+ }
127
+ // Reject NaN/Infinity and clamp at 0. `0` is permitted so callers can opt out
128
+ // of backoff entirely (e.g. for fast-path tests); `Infinity` would otherwise
129
+ // stall the retry loop indefinitely on the very first backoff.
130
+ function sanitiseBaseMs(value, fallback) {
131
+ if (value === undefined || !Number.isFinite(value))
132
+ return fallback;
133
+ return Math.max(0, Math.floor(value));
134
+ }
135
+ // Reject NaN/Infinity and snap to fallback for any value below 1. Diverges
136
+ // from sanitiseAttempts (which floors at 1) because zero workers here is
137
+ // not a "fail-fast once" semantic — `Math.min(0, queue.length) = 0` at the
138
+ // worker fan-out site below produces zero S3 PUTs, so /multipart/complete
139
+ // is then called with an incomplete `parts` array (silent corruption).
140
+ // Garbage input almost certainly meant "use the default", not "send no parts".
141
+ function sanitiseConcurrency(value, fallback) {
142
+ if (value === undefined || !Number.isFinite(value))
143
+ return fallback;
144
+ const floored = Math.floor(value);
145
+ return floored < 1 ? fallback : floored;
146
+ }
147
+ // Cancellable sleep. Resolves after `ms` ms, rejects with GislAbortError if
148
+ // the caller's signal aborts, or resolves early (without throwing) if the
149
+ // internal `wakeSignal` fires — that path lets a sibling worker's terminal
150
+ // failure short-circuit a peer's backoff sleep without producing a spurious
151
+ // abort error in the peer's own throw stack.
152
+ function sleepWithEitherSignal(ms, abortSignal, wakeSignal) {
153
+ if (abortSignal?.aborted) {
154
+ return Promise.reject(new GislAbortError('Multipart upload aborted'));
155
+ }
156
+ if (wakeSignal.aborted || ms <= 0)
157
+ return Promise.resolve();
158
+ return new Promise((resolve, reject) => {
159
+ const cleanup = () => {
160
+ abortSignal?.removeEventListener('abort', onAbort);
161
+ wakeSignal.removeEventListener('abort', onWake);
162
+ };
163
+ const onAbort = () => {
164
+ clearTimeout(timer);
165
+ cleanup();
166
+ reject(new GislAbortError('Multipart upload aborted'));
167
+ };
168
+ const onWake = () => {
169
+ clearTimeout(timer);
170
+ cleanup();
171
+ resolve();
172
+ };
173
+ const timer = setTimeout(() => {
174
+ cleanup();
175
+ resolve();
176
+ }, ms);
177
+ abortSignal?.addEventListener('abort', onAbort, { once: true });
178
+ wakeSignal.addEventListener('abort', onWake, { once: true });
179
+ });
180
+ }
181
+ // Wire an optional external AbortSignal onto an internal per-request controller
182
+ // so either source trips the composed fetch. The `onExternalAbort` callback
183
+ // fires the moment the external signal aborts — callers use it to capture the
184
+ // temporal order of user-vs-timeout causes, so the tiebreak in request() does
185
+ // not rely on `signal.aborted` read at catch time (which flips true regardless
186
+ // of which cause actually fired first).
187
+ //
188
+ // Returns a teardown that removes the listener — must be called in a finally
189
+ // so long-lived user AbortControllers do not accumulate listeners across many
190
+ // uploads. Node 18+ compatible (no AbortSignal.any).
191
+ function bindAbortSignal(external, internal, onExternalAbort) {
192
+ if (!external)
193
+ return () => { };
194
+ if (external.aborted) {
195
+ onExternalAbort?.();
196
+ internal.abort();
197
+ return () => { };
198
+ }
199
+ const onAbort = () => {
200
+ onExternalAbort?.();
201
+ internal.abort();
202
+ };
203
+ external.addEventListener('abort', onAbort, { once: true });
204
+ return () => external.removeEventListener('abort', onAbort);
205
+ }
206
+ // Blob/File input is already lazy: `Blob.slice()` is a zero-copy view and a
207
+ // `File` from a browser picker is disk-backed, so this branch never buffered
208
+ // the whole file. Left structurally identical to the pre-streaming-rewrite
209
+ // behaviour.
210
+ function blobByteSource(blob) {
211
+ return {
212
+ size: blob.size,
213
+ // Pass `blob.type` as the 3rd arg: `Blob.slice()` defaults the slice's
214
+ // content-type to '' otherwise, which would strip the MIME type off the
215
+ // single-shot FormData part (the pre-streaming code appended the original
216
+ // typed Blob directly). Parity fixtures pin this content-type.
217
+ slice: (start, end) => Promise.resolve(blob.slice(start, end, blob.type)),
218
+ };
219
+ }
220
+ // File-path input. The pre-rewrite code did `readFileSync(path)` →
221
+ // `new Blob([whole file])`, which (a) OOMs on multi-GB files and (b) cannot
222
+ // even be attempted above ~2 GB because a single libuv `uv_fs_read` is capped
223
+ // at INT32_MAX (see LIBUV_MAX_SINGLE_READ_BYTES). This source instead does a
224
+ // positioned (POSIX pread-semantics) read of ONLY the requested range, with a
225
+ // fresh fd per call so concurrent multipart workers never share a FileHandle
226
+ // (overlapping reads on one handle are unsafe per the Node fs contract) and
227
+ // the fd is always closed in `finally`.
228
+ //
229
+ // Divergence from the old Blob-from-readFileSync behaviour (deliberate, in
230
+ // scope only for streaming): the old path snapshotted the whole file at t0,
231
+ // so every part was point-in-time consistent. Streaming reads each part at
232
+ // the time it is uploaded, so a file truncated/rewritten mid-upload now
233
+ // yields parts from different instants. Truncation is caught by the
234
+ // short-read guard below; full point-in-time snapshotting would require
235
+ // resumable/staged upload and is out of scope (SDK-3, Wb6ebOMM).
236
+ function fileByteSource(path, size) {
237
+ return {
238
+ size,
239
+ async slice(start, end) {
240
+ const length = end - start;
241
+ if (length <= 0)
242
+ return new Blob([]);
243
+ // Per-read tripwire for the libuv INT32_MAX ceiling. Unreachable on the
244
+ // normal path (chunk size <=100 MiB) — exists so a future oversized
245
+ // caller fails here loudly instead of getting a silent short read.
246
+ if (length > LIBUV_MAX_SINGLE_READ_BYTES) {
247
+ throw new GislError(`Refusing to read ${length} bytes in one operation: exceeds the ` +
248
+ `libuv single-read ceiling (${LIBUV_MAX_SINGLE_READ_BYTES}). ` +
249
+ 'Reads must be chunked below INT32_MAX.');
250
+ }
251
+ const handle = await open(path, 'r');
252
+ try {
253
+ const buffer = Buffer.allocUnsafe(length);
254
+ const { bytesRead } = await handle.read(buffer, 0, length, start);
255
+ if (bytesRead !== length) {
256
+ // Short read = the file shrank/was truncated under us. Mirrors the
257
+ // PHP SDK's readChunk short-read guard (GislClient.php readChunk).
258
+ throw new GislError(`Short read on ${path}: expected ${length} bytes at offset ` +
259
+ `${start}, got ${bytesRead}. File changed during upload.`);
260
+ }
261
+ return new Blob([buffer]);
262
+ }
263
+ finally {
264
+ await handle.close();
265
+ }
266
+ },
267
+ };
268
+ }
29
269
  export class GislClient {
30
270
  baseUrl;
31
271
  headers;
32
272
  timeoutMs;
33
273
  multipartThreshold;
34
274
  multipartConcurrency;
275
+ multipartMaxAttempts;
276
+ multipartRetryBaseMs;
277
+ useSessionCookie;
35
278
  constructor(config) {
36
279
  this.baseUrl = config.baseUrl.replace(/\/+$/, '');
37
280
  this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
281
+ this.useSessionCookie = config.useSessionCookie ?? false;
38
282
  // Floor the threshold at the first-chunk size: the multipart initiate
39
283
  // must always carry an 8MB chunk, so routing a sub-8MB file into the
40
284
  // 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;
285
+ this.multipartThreshold = Math.max(config.multipartThreshold ?? SINGLE_SHOT_MAX_BYTES, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
286
+ this.multipartConcurrency = sanitiseConcurrency(config.multipartConcurrency, MULTIPART_CONCURRENCY_DEFAULT);
287
+ // Sanitise: reject NaN/Infinity (the former would cause `attempt < NaN`
288
+ // to be perpetually false, skipping every PUT; the latter would retry
289
+ // unboundedly). Floor at 1 so a misconfigured 0/negative still attempts
290
+ // once.
291
+ this.multipartMaxAttempts = sanitiseAttempts(config.multipartMaxAttempts, DEFAULT_MULTIPART_MAX_ATTEMPTS);
292
+ this.multipartRetryBaseMs = sanitiseBaseMs(config.multipartRetryBaseMs, DEFAULT_MULTIPART_RETRY_BASE_MS);
43
293
  this.headers = { ...config.headers };
44
294
  if (config.apiKey) {
45
295
  this.headers['Authorization'] = `Bearer ${config.apiKey}`;
@@ -49,8 +299,12 @@ export class GislClient {
49
299
  // Internal HTTP
50
300
  // -----------------------------------------------------------------------
51
301
  async request(method, path, opts = {}) {
302
+ // Fast-fail on a pre-aborted user signal before building the request.
303
+ if (opts.signal?.aborted) {
304
+ throw new GislAbortError(`Request to ${method} ${path} aborted`);
305
+ }
52
306
  const url = `${this.baseUrl}${path}`;
53
- const headers = { ...this.headers };
307
+ const headers = { ...this.headers, ...opts.headers };
54
308
  let body;
55
309
  if (opts.json !== false && opts.body && !(opts.body instanceof FormData)) {
56
310
  headers['Content-Type'] = 'application/json';
@@ -60,7 +314,22 @@ export class GislClient {
60
314
  body = opts.body;
61
315
  }
62
316
  const controller = new AbortController();
63
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
317
+ // Record the first cause that tripped the composed controller. Checking
318
+ // `opts.signal.aborted` alone at catch time is not sound: if the timer
319
+ // fires first and the user signal aborts microseconds later (before
320
+ // `catch` runs), that flag is also true — but the true cause was the
321
+ // timeout. Capturing which side fired first gives a deterministic
322
+ // classification regardless of scheduling.
323
+ let firstCause = null;
324
+ const timer = setTimeout(() => {
325
+ if (firstCause === null)
326
+ firstCause = 'timeout';
327
+ controller.abort();
328
+ }, this.timeoutMs);
329
+ const unbind = bindAbortSignal(opts.signal, controller, () => {
330
+ if (firstCause === null)
331
+ firstCause = 'user';
332
+ });
64
333
  let response;
65
334
  try {
66
335
  response = await fetch(url, {
@@ -68,20 +337,35 @@ export class GislClient {
68
337
  headers,
69
338
  body,
70
339
  signal: controller.signal,
340
+ // `credentials: 'include'` on every request when the consumer opts
341
+ // into cookie-based auth (Symfony session via /api/auth/login).
342
+ // No-op in Node (fetch ignores the field there); mandatory for
343
+ // cross-origin browser SPAs to send the session cookie.
344
+ ...(this.useSessionCookie ? { credentials: 'include' } : {}),
71
345
  });
72
346
  }
73
347
  catch (err) {
74
- if (err instanceof DOMException && err.name === 'AbortError') {
348
+ if (isAbortError(err)) {
349
+ if (firstCause === 'user') {
350
+ throw new GislAbortError(`Request to ${method} ${path} aborted`);
351
+ }
75
352
  throw new GislTimeoutError(`Request to ${method} ${path} timed out after ${this.timeoutMs}ms`);
76
353
  }
77
354
  throw err;
78
355
  }
79
356
  finally {
80
357
  clearTimeout(timer);
358
+ unbind();
81
359
  }
82
360
  if (opts.rawResponse) {
83
361
  return response;
84
362
  }
363
+ // 204 No Content — contracted success status for endpoints that return
364
+ // no body (e.g. POST /api/contact). Short-circuit before handleResponse
365
+ // so an empty body never trips the JSON parser.
366
+ if (response.status === 204) {
367
+ return undefined;
368
+ }
85
369
  return this.handleResponse(response, path, opts.deserialize);
86
370
  }
87
371
  async handleResponse(response, path, deserialize) {
@@ -93,6 +377,10 @@ export class GislClient {
93
377
  }
94
378
  return undefined;
95
379
  }
380
+ // Wire-side fields are snake_case (raw response.json() — never run through
381
+ // FromJSON helpers here). The structured-error subclasses receive a typed
382
+ // payload built via the per-envelope FromJSON helper, which handles the
383
+ // snake_case -> camelCase conversion for nested fields.
96
384
  let json;
97
385
  try {
98
386
  json = await response.json();
@@ -100,23 +388,149 @@ export class GislClient {
100
388
  catch {
101
389
  throw new GislApiError(response.status, 'Invalid JSON response', path);
102
390
  }
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
391
  // Standard envelope: { success, data } or { success, error, details }
111
392
  if (!response.ok || json.success === false) {
393
+ // Localisation triple per ticket I26 — surfaced on every typed error so
394
+ // consumers can drive client-side i18n catalogs without unwrapping the
395
+ // typed payload. Field names are wire snake_case here; the typed payload
396
+ // (built via FromJSON below) carries camelCase copies.
397
+ const i18n = {
398
+ messageKey: json.message_key,
399
+ locale: json.locale,
400
+ messageParams: json.message_params,
401
+ };
402
+ // Validation-details branch first — preserve existing shape so callers
403
+ // matching on `instanceof GislValidationError` keep working.
112
404
  if (isValidationDetails(json.details)) {
113
- throw new GislValidationError(response.status, json.error ?? 'Validation error', json.details, path);
405
+ throw new GislValidationError(response.status, json.error ?? 'Validation error', json.details, path, i18n);
114
406
  }
115
- throw new GislApiError(response.status, json.error ?? 'Unknown error', path, json.details);
407
+ // Dispatch by (status, error_type) onto the structured envelope shapes
408
+ // emitted by the v2 contracts. Each branch builds the typed payload via
409
+ // the generated FromJSON helper so consumers reading e.g.
410
+ // `error.payload.errorType` see camelCase fields rather than the raw
411
+ // wire snake_case.
412
+ //
413
+ // Defense-in-depth: if a malformed wire envelope causes the FromJSON
414
+ // helper to throw or coerce a required field to a sentinel value
415
+ // (e.g. `expired_at` missing -> `new Date(undefined)` => Invalid Date),
416
+ // fall through to the base `GislApiError` rather than handing the
417
+ // caller silently-corrupted typed metadata.
418
+ const errorType = json.error_type;
419
+ const status = response.status;
420
+ const errorMessage = json.error ?? 'Unknown error';
421
+ // Build the typed payload via FromJSON, then validate that all
422
+ // required typed fields are well-formed. FromJSON does not throw on
423
+ // missing required fields — for example `workflow_expired` without
424
+ // `expired_at` produces `new Date(undefined)` => Invalid Date with
425
+ // `getTime() === NaN`. Without an explicit validity check the error
426
+ // would surface a silently-corrupted typed payload instead of falling
427
+ // through to the generic base class.
428
+ const tryThrowStructured = (construct, ErrorClass, validate) => {
429
+ let payload;
430
+ try {
431
+ payload = construct(json);
432
+ }
433
+ catch {
434
+ return undefined;
435
+ }
436
+ if (validate && !validate(payload)) {
437
+ return undefined;
438
+ }
439
+ throw new ErrorClass(status, errorMessage, payload, path, i18n);
440
+ };
441
+ const isValidDate = (d) => d instanceof Date && !Number.isNaN(d.getTime());
442
+ if (status === 401 || status === 403) {
443
+ if (errorType && this.isAuthErrorType(errorType)) {
444
+ tryThrowStructured(AuthErrorResponseFromJSON, GislAuthError, (p) => typeof p.errorType === 'string');
445
+ }
446
+ }
447
+ const isInEnum = (value, members) => typeof value === 'string' && Object.values(members).includes(value);
448
+ const isFeatureViolation = (v) => typeof v === 'object' && v !== null
449
+ && typeof v.feature === 'string';
450
+ if (status === 402 && errorType === 'balance_exhausted') {
451
+ tryThrowStructured(BalanceExhaustedResponseFromJSON, GislBalanceExhaustedError, (p) => isInEnum(p.requiredAction, BalanceExhaustedResponseRequiredActionEnum));
452
+ }
453
+ if (status === 403 && errorType === 'tier_restriction') {
454
+ tryThrowStructured(TierRestrictionResponseFromJSON, GislTierRestrictedError, (p) => isInEnum(p.restrictionKind, TierRestrictionKind)
455
+ && isInEnum(p.currentTier, UserTier));
456
+ }
457
+ if (status === 403 && errorType === 'feature_tier_restricted') {
458
+ tryThrowStructured(FeatureTierRestrictedResponseFromJSON, GislFeatureTierRestrictedError, (p) => Array.isArray(p.violations) && p.violations.every(isFeatureViolation));
459
+ }
460
+ if (status === 422 && errorType === 'feature_not_available') {
461
+ tryThrowStructured(FeatureNotAvailableResponseFromJSON, GislFeatureNotAvailableError, (p) => Array.isArray(p.violations) && p.violations.every(isFeatureViolation));
462
+ }
463
+ if (status === 422 && errorType === 'workflow_expired') {
464
+ tryThrowStructured(WorkflowExpiredResponseFromJSON, GislWorkflowExpiredError, (p) => isValidDate(p.expiredAt));
465
+ }
466
+ // Upload cap errors. `GislUploadCapExceededError` takes an extra `kind`
467
+ // arg so it cannot use `tryThrowStructured` (whose ErrorClass signature
468
+ // is fixed) — this local helper applies the SAME defense-in-depth
469
+ // discipline: construct via FromJSON, validate required typed fields,
470
+ // fall through to the generic `GislApiError` on any malformed envelope.
471
+ const tryThrowCap = (construct, kind, validate) => {
472
+ let payload;
473
+ try {
474
+ payload = construct(json);
475
+ }
476
+ catch {
477
+ return undefined;
478
+ }
479
+ if (!validate(payload)) {
480
+ return undefined;
481
+ }
482
+ throw new GislUploadCapExceededError(status, errorMessage, kind, payload, path, i18n);
483
+ };
484
+ if (status === 422 && errorType === 'upload_size_exceeds_tier') {
485
+ tryThrowCap(UploadSizeExceedsTierResponseFromJSON, 'size_tier', (p) => isInEnum(p.currentTier, UserTier) &&
486
+ typeof p.maxSizeBytes === 'number');
487
+ }
488
+ if (status === 422 && errorType === 'upload_duration_exceeds_tier') {
489
+ tryThrowCap(UploadDurationExceedsTierResponseFromJSON, 'duration_tier', (p) => isInEnum(p.currentTier, UserTier) &&
490
+ typeof p.maxDurationSeconds === 'number');
491
+ }
492
+ // 413 = the absolute across-tier cap. The contract models 413 as a
493
+ // plain `ErrorEnvelope` (no `error_type` discriminator, no typed
494
+ // payload — api.yaml), so dispatch purely on status with no FromJSON
495
+ // and an undefined payload (the `absolute_413` kind tells the caller
496
+ // there is intentionally no structured envelope to read).
497
+ if (status === 413) {
498
+ throw new GislUploadCapExceededError(status, errorMessage, 'absolute_413', undefined, path, i18n);
499
+ }
500
+ // SDK-3 (Wb6ebOMM) resume-support endpoint error codes. API-2 / PR
501
+ // #283 specced these as plain `ErrorEnvelope` envelopes with the
502
+ // discriminating string on `error_type`. No typed payload to build —
503
+ // dispatch on the (status, error_type) tuple. The HxUmVr3Y contract
504
+ // regen will produce typed responses for these; today the 3 typed
505
+ // subclasses carry only the localisation triple + raw envelope.
506
+ if (status === 404 && errorType === 'MULTIPART_SESSION_NOT_FOUND') {
507
+ throw new GislMultipartSessionNotFoundError(status, errorMessage, path, i18n);
508
+ }
509
+ if (status === 403 && errorType === 'MULTIPART_SESSION_OWNERSHIP') {
510
+ throw new GislMultipartSessionOwnershipError(status, errorMessage, path, i18n);
511
+ }
512
+ if (status === 403 && errorType === 'MULTIPART_SESSION_AUTH_REQUIRED') {
513
+ throw new GislMultipartSessionAuthRequiredError(status, errorMessage, path, i18n);
514
+ }
515
+ // 422 `FILE_TOO_LARGE_FOR_MULTIPART` — pre-S3 capacity reject on the
516
+ // resume-support presign endpoint (more parts than the manifest can
517
+ // ever accept). No typed payload today (the contract carries no
518
+ // structured response for this code); `cap_v2_multipart` discriminant
519
+ // is documented on `GislUploadCapKind`.
520
+ if (status === 422 && errorType === 'FILE_TOO_LARGE_FOR_MULTIPART') {
521
+ throw new GislUploadCapExceededError(status, errorMessage, 'cap_v2_multipart', undefined, path, i18n);
522
+ }
523
+ throw new GislApiError(status, errorMessage, path, json.details, { ...i18n, payload: json });
116
524
  }
117
525
  const data = json.data ?? json;
118
526
  return deserialize ? deserialize(data) : data;
119
527
  }
528
+ // Membership check for the AuthErrorType discriminator. Reads the generated
529
+ // enum object directly so a future contract addition lands here without a
530
+ // hand-edit. The bundle cost is one tiny `as const` literal map (8 entries).
531
+ isAuthErrorType(value) {
532
+ return Object.values(AuthErrorType).includes(value);
533
+ }
120
534
  // -----------------------------------------------------------------------
121
535
  // Upload
122
536
  // -----------------------------------------------------------------------
@@ -128,33 +542,60 @@ export class GislClient {
128
542
  * @param options Upload options including progress callback.
129
543
  */
130
544
  async uploadFile(file, options) {
131
- let blob;
545
+ // Pre-abort check: bail before touching the filesystem when the caller
546
+ // has already cancelled.
547
+ if (options?.signal?.aborted) {
548
+ throw new GislAbortError('Upload aborted before start');
549
+ }
550
+ let source;
132
551
  let fileName;
133
- let fileSize;
134
552
  if (typeof file === 'string') {
135
- const stat = statSync(file);
136
- fileSize = stat.size;
553
+ // `stat` for the size only — the bytes are NEVER read up front. The old
554
+ // path did `readFileSync(file)` which OOMs on multi-GB files and is
555
+ // impossible above the libuv INT32_MAX single-read ceiling regardless
556
+ // of available memory (see fileByteSource / LIBUV_MAX_SINGLE_READ_BYTES).
557
+ const stats = await stat(file);
137
558
  fileName = basename(file);
138
- const content = readFileSync(file);
139
- blob = new Blob([content]);
559
+ source = fileByteSource(file, stats.size);
140
560
  }
141
561
  else {
142
- blob = file;
143
562
  fileName = file.name ?? 'upload';
144
- fileSize = file.size;
563
+ source = blobByteSource(file);
145
564
  }
146
- if (fileSize > this.multipartThreshold) {
147
- return this.multipartUpload(blob, fileName, fileSize, options);
565
+ if (typeof options?.resumeUploadId === 'string' && options.resumeUploadId !== '') {
566
+ // SDK-3 (Wb6ebOMM): resume path takes the durable session's
567
+ // `recommended_chunk_size` from the /status envelope rather than
568
+ // the initiate envelope (initiate is skipped). Below the multipart
569
+ // threshold a resume is still meaningful — the original session was
570
+ // started as multipart, so a sub-threshold file CAN'T be a "resume
571
+ // target" in practice. Guard explicitly so a confused caller gets a
572
+ // clear error rather than a 404 on /status.
573
+ if (source.size <= this.multipartThreshold) {
574
+ throw new GislError('uploadFile: resumeUploadId set but file size is at-or-below the multipart ' +
575
+ `threshold (${this.multipartThreshold} bytes); resume targets must be multipart sessions.`);
576
+ }
577
+ return this.multipartResume(source, fileName, source.size, options.resumeUploadId, options);
148
578
  }
149
- return this.singleUpload(blob, fileName);
579
+ if (source.size > this.multipartThreshold) {
580
+ return this.multipartUpload(source, fileName, source.size, options);
581
+ }
582
+ return this.singleUpload(source, fileName, options);
150
583
  }
151
- async singleUpload(blob, fileName) {
584
+ async singleUpload(source, fileName, options) {
152
585
  const form = new FormData();
153
- form.append('file', blob, fileName);
586
+ // Single-shot is gated to <= single_shot_max_bytes (10 MB) by the router
587
+ // above, so this one bounded read is trivially under the libuv ceiling
588
+ // and a non-issue for memory. `slice` returns a Blob (the file-path
589
+ // source wraps the bounded Buffer) so FormData.append is unchanged —
590
+ // multipart never wraps a whole-file Blob, only this <=10 MB single-shot
591
+ // path ever holds a full payload Blob.
592
+ const body = await source.slice(0, source.size);
593
+ form.append('file', body, fileName);
154
594
  return this.request('POST', '/api/uploads', {
155
595
  body: form,
156
596
  json: false,
157
597
  deserialize: UploadResponseFromJSON,
598
+ signal: options?.signal,
158
599
  });
159
600
  }
160
601
  /**
@@ -168,18 +609,31 @@ export class GislClient {
168
609
  * comes from the initiate response's first-chunk detection; for authoritative
169
610
  * post-upload metadata callers should use getMetadata(fileId).
170
611
  */
171
- async multipartUpload(blob, fileName, totalSize, options) {
612
+ async multipartUpload(source, fileName, totalSize, options) {
172
613
  // Step 1: Initiate with first chunk
173
614
  const firstChunkSize = Math.min(totalSize, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
174
- const firstChunk = blob.slice(0, firstChunkSize);
615
+ const firstChunk = await source.slice(0, firstChunkSize);
175
616
  const initiateForm = new FormData();
176
617
  initiateForm.append('file', firstChunk, fileName);
177
618
  initiateForm.append('filename', fileName);
178
619
  initiateForm.append('total_size', totalSize.toString());
620
+ if (options?.metadataHint !== undefined) {
621
+ // Wire format: a single FormData field carrying the JSON-stringified
622
+ // hint object. Single-shot uploads do not accept this field — see
623
+ // singleUpload() which silently ignores `options.metadataHint`.
624
+ // Route through the generated ToJSON helper so the wire form is the
625
+ // contract-pinned snake_case shape (`duration_seconds`, `width`,
626
+ // `height`). Stringify-ing the camelCase TS object directly would
627
+ // emit `durationSeconds` and the server would silently drop it,
628
+ // defeating the hint's primary use (long-form pre-classification when
629
+ // the first-chunk probe lacks container metadata).
630
+ initiateForm.append('metadata_hint', JSON.stringify(MultipartInitiateRequestMetadataHintToJSON(options.metadataHint)));
631
+ }
179
632
  const initResponse = await this.request('POST', '/api/uploads/multipart/initiate', {
180
633
  body: initiateForm,
181
634
  json: false,
182
635
  deserialize: MultipartInitiateResponseFromJSON,
636
+ signal: options?.signal,
183
637
  });
184
638
  let uploadedBytes = firstChunkSize;
185
639
  options?.onProgress?.(uploadedBytes, totalSize);
@@ -187,44 +641,265 @@ export class GislClient {
187
641
  const etags = [];
188
642
  const presignedUrls = initResponse.presignedUrls;
189
643
  const chunkSize = initResponse.recommendedChunkSize;
644
+ // MultipartInitiateResponseFromJSON does NO runtime validation (unlike
645
+ // the strict PHP generated model, which rejects these at deserialize —
646
+ // the documented lax-TS-vs-strict-PHP divergence). The TS SDK must
647
+ // therefore enforce, before any chunk read/PUT, what PHP gets for free
648
+ // from its generated model + its explicit pre-loop guards (codex review):
649
+ //
650
+ // (a) uploadId must be a non-empty string. `FromJSON` assigns
651
+ // `json['upload_id']` directly, so a malformed initiate could
652
+ // otherwise produce a typed GislMultipartPartError whose `uploadId`
653
+ // is `undefined` and synthesise a bogus UploadResponse.fileId —
654
+ // mirrors the PHP pre-loop `is_string && !== ''` guard.
655
+ if (typeof initResponse.uploadId !== 'string' ||
656
+ initResponse.uploadId === '') {
657
+ throw new GislError('Multipart initiate response missing or empty upload_id.');
658
+ }
659
+ // (b) recommendedChunkSize must be a finite number INSIDE the contract
660
+ // range [MULTIPART_CHUNK_SIZE, RECOMMENDED_CHUNK_SIZE_MAX_BYTES]. The
661
+ // old `>= 1` check let a malformed/hostile huge value pass the
662
+ // part-count guard and drive `fileByteSource` into an unbounded
663
+ // `Buffer.allocUnsafe(length)` — the memory-blowup class this SDK
664
+ // exists to prevent. PHP's strict generated model already rejects
665
+ // out-of-range values at deserialize; this is the TS equivalent.
666
+ if (typeof chunkSize !== 'number' ||
667
+ !Number.isInteger(chunkSize) ||
668
+ chunkSize < MULTIPART_CHUNK_SIZE ||
669
+ chunkSize > RECOMMENDED_CHUNK_SIZE_MAX_BYTES) {
670
+ // `Number.isInteger` also rejects NaN/Infinity and a fractional
671
+ // `recommended_chunk_size` (e.g. 5242880.5) that would otherwise
672
+ // reach `Buffer.allocUnsafe(fractional)` and fail later as a
673
+ // misleading part-read error (codex review).
674
+ throw new GislError('Multipart initiate response recommendedChunkSize is missing or ' +
675
+ `outside the contract range [${MULTIPART_CHUNK_SIZE}, ` +
676
+ `${RECOMMENDED_CHUNK_SIZE_MAX_BYTES}]: got ${String(chunkSize)}.`);
677
+ }
678
+ // S3 <=10 000-part ceiling guard (Model A). The server computes and
679
+ // returns `totalParts`; we trust it (consistent with how the SDK already
680
+ // trusts `recommendedChunkSize`/`presignedUrls` from the same envelope)
681
+ // but assert the ceiling, cross-checked against a client-side recompute
682
+ // from the same `chunkSize`. This necessarily fires AFTER the initiate
683
+ // round-trip + 8 MiB first-chunk upload — `totalParts` and `chunkSize`
684
+ // only exist on the initiate response, so a pure pre-flight check is
685
+ // impossible under Model A (this is the card-mandated trade-off).
686
+ const remainingBytes = Math.max(0, totalSize - firstChunkSize);
687
+ const computedParts = 1 + Math.ceil(remainingBytes / chunkSize);
688
+ const serverParts = initResponse.totalParts;
689
+ // `FromJSON` passes `total_parts` through unvalidated. Reject a
690
+ // missing/non-integer value here so the ≤10k guard's
691
+ // `Math.max(serverParts, computedParts)` cannot surface `NaN` in the
692
+ // GislMultipartPartCountError (codex review). Mirrors the uploadId /
693
+ // chunkSize guards above (the lax-TS-vs-strict-PHP-model divergence).
694
+ if (typeof serverParts !== 'number' ||
695
+ !Number.isInteger(serverParts) ||
696
+ serverParts < 1) {
697
+ throw new GislError('Multipart initiate response missing or invalid total_parts: ' +
698
+ `got ${String(serverParts)}.`);
699
+ }
700
+ if (serverParts > S3_MAX_MULTIPART_PARTS ||
701
+ computedParts > S3_MAX_MULTIPART_PARTS) {
702
+ throw new GislMultipartPartCountError(`Upload requires ${Math.max(serverParts, computedParts)} parts, ` +
703
+ `exceeding the S3 ${S3_MAX_MULTIPART_PARTS}-part multipart limit ` +
704
+ `(server reported ${serverParts}, client computed ${computedParts} ` +
705
+ `at ${chunkSize}-byte chunks). A larger chunk size is required ` +
706
+ 'server-side to upload a file this large.', Math.max(serverParts, computedParts), S3_MAX_MULTIPART_PARTS);
707
+ }
708
+ // Plan-consistency guard (codex review). The ≤10k ceiling above only
709
+ // bounds the count; it does NOT catch an initiate plan that is internally
710
+ // inconsistent BELOW the cap. Under Model A a contract-compliant server
711
+ // computes `total_parts` from the same `recommended_chunk_size` it
712
+ // returns, and emits exactly one presigned URL per remaining part (part 1
713
+ // is the initiate first chunk). If `total_parts`, the client recompute,
714
+ // and `presigned_urls.length` disagree, proceeding would PUT the wrong
715
+ // number of byte ranges (or wrong offsets) and only fail opaquely at
716
+ // /multipart/complete. Fail fast here with the discrepancy instead.
717
+ if (!Number.isFinite(serverParts) ||
718
+ serverParts !== computedParts ||
719
+ presignedUrls.length !== computedParts - 1) {
720
+ throw new GislError('Multipart initiate plan is internally inconsistent: server ' +
721
+ `total_parts=${serverParts}, client computed ${computedParts} ` +
722
+ `from ${chunkSize}-byte chunks, presigned_urls.length=` +
723
+ `${presignedUrls.length} (expected ${computedParts - 1}). ` +
724
+ 'Refusing to upload a mismatched part plan.');
725
+ }
726
+ // Internal abort signal that workers use to short-circuit each others'
727
+ // backoff sleeps. When any worker hits a terminal failure it aborts this
728
+ // controller, which races the caller's signal inside sleepWithSignal so
729
+ // sleeping siblings stop waiting for their timer to expire.
730
+ const failureController = new AbortController();
731
+ // Single PUT attempt. Returns a structured outcome instead of throwing
732
+ // for retryable/non-retryable distinctions, so the caller can decide
733
+ // whether to loop without conflating user-callback errors with
734
+ // network-layer retries (codex review).
735
+ const attemptPut = async (part, chunk, contentLength) => {
736
+ let s3Response;
737
+ try {
738
+ s3Response = await fetch(part.url, {
739
+ method: 'PUT',
740
+ body: chunk,
741
+ headers: { 'Content-Length': contentLength.toString() },
742
+ signal: options?.signal,
743
+ });
744
+ }
745
+ catch (err) {
746
+ if (isAbortError(err) && options?.signal?.aborted) {
747
+ return {
748
+ kind: 'fatal',
749
+ err: new GislAbortError(`S3 part ${part.partNumber} upload aborted`),
750
+ };
751
+ }
752
+ if (isRetryableNetworkError(err)) {
753
+ return { kind: 'retryable', lastErr: err };
754
+ }
755
+ return { kind: 'fatal', err };
756
+ }
757
+ if (s3Response.ok) {
758
+ const etag = s3Response.headers.get('etag');
759
+ if (!etag) {
760
+ // Drain the body even though we're failing fast — keeps the
761
+ // connection released eagerly.
762
+ await drainResponseBody(s3Response);
763
+ return {
764
+ kind: 'fatal',
765
+ err: new GislError(`S3 response missing ETag for part ${part.partNumber}`),
766
+ };
767
+ }
768
+ return { kind: 'ok', etag };
769
+ }
770
+ // Non-OK: drain the body in BOTH branches before deciding. Undici
771
+ // holds the connection open until the body is consumed regardless of
772
+ // whether we retry.
773
+ await drainResponseBody(s3Response);
774
+ if (!isRetryableStatus(s3Response.status)) {
775
+ return {
776
+ kind: 'fatal',
777
+ err: new GislError(`S3 chunk upload failed for part ${part.partNumber}: ${s3Response.status}`),
778
+ };
779
+ }
780
+ return {
781
+ kind: 'retryable',
782
+ lastErr: new GislError(`S3 chunk upload failed for part ${part.partNumber}: ${s3Response.status}`),
783
+ };
784
+ };
190
785
  const uploadChunk = async (index) => {
191
786
  const part = presignedUrls[index];
192
787
  const start = firstChunkSize + index * chunkSize;
193
788
  const end = Math.min(start + chunkSize, totalSize);
194
- 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}`);
789
+ // Read this part's bytes ONCE here, then reuse the captured chunk
790
+ // across every retry attempt below — so a retry never re-reads the
791
+ // file and the re-PUT is byte-identical (S3 parts are idempotent by
792
+ // partNumber; a re-PUT overwrites, no duplicate-data risk).
793
+ //
794
+ // Live-file caveat (streaming divergence from the old
795
+ // readFileSync→Blob path): the old code snapshotted the whole file at
796
+ // t0 so every part was point-in-time consistent. Streaming reads each
797
+ // part at the instant it is first uploaded, so a file mutated
798
+ // mid-upload yields parts from different instants. Truncation is
799
+ // caught by fileByteSource's short-read guard; full point-in-time
800
+ // snapshotting is resumable/staged-upload territory (SDK-3, Wb6ebOMM).
801
+ // Surface a read failure for THIS part as the typed
802
+ // GislMultipartPartError (with partNumber + uploadId), consistent with
803
+ // the PUT-failure path below — a bare GislError from fileByteSource
804
+ // (short read / libuv ceiling) would otherwise lose the per-part
805
+ // context (codex review). An abort must stay GislAbortError.
806
+ let chunk;
807
+ try {
808
+ chunk = await source.slice(start, end);
809
+ }
810
+ catch (err) {
811
+ if (err instanceof GislAbortError)
812
+ throw err;
813
+ throw new GislMultipartPartError(`Failed to read bytes for part ${part.partNumber}: ` +
814
+ (err instanceof Error ? err.message : String(err)), part.partNumber, initResponse.uploadId);
202
815
  }
203
- const etag = s3Response.headers.get('etag');
204
- if (!etag) {
205
- throw new GislError(`S3 response missing ETag for part ${part.partNumber}`);
816
+ const contentLength = end - start;
817
+ let lastErr = null;
818
+ for (let attempt = 0; attempt < this.multipartMaxAttempts; attempt++) {
819
+ if (options?.signal?.aborted) {
820
+ throw new GislAbortError(`S3 part ${part.partNumber} upload aborted`);
821
+ }
822
+ if (failureController.signal.aborted) {
823
+ // Sibling worker hit a terminal failure: bail before dispatching a
824
+ // wasted PUT. The thrown error is swallowed by the outer worker
825
+ // loop — Promise.all has already settled with the first failure.
826
+ throw new GislError(`S3 part ${part.partNumber} upload abandoned after sibling worker failure`);
827
+ }
828
+ const outcome = await attemptPut(part, chunk, contentLength);
829
+ if (outcome.kind === 'ok') {
830
+ // Apply progress side effects OUTSIDE the retry-scoped path so a
831
+ // user-callback throw does not trigger a duplicate PUT (codex
832
+ // review: retrying after a successful PUT would double-record the
833
+ // ETag and re-upload an already accepted part).
834
+ etags.push({ partNumber: part.partNumber, etag: outcome.etag });
835
+ uploadedBytes += contentLength;
836
+ options?.onProgress?.(uploadedBytes, totalSize);
837
+ return;
838
+ }
839
+ if (outcome.kind === 'fatal') {
840
+ throw outcome.err;
841
+ }
842
+ lastErr = outcome.lastErr;
843
+ if (attempt + 1 >= this.multipartMaxAttempts)
844
+ break;
845
+ const delay = fullJitterDelay(this.multipartRetryBaseMs, attempt);
846
+ // Race the caller's signal AND the sibling-failure signal so a
847
+ // worker that fails terminally wakes its sleeping peers instead of
848
+ // forcing them to wait out their backoff timer.
849
+ await sleepWithEitherSignal(delay, options?.signal, failureController.signal);
206
850
  }
207
- etags.push({ part_number: part.partNumber, etag });
208
- uploadedBytes += end - start;
209
- options?.onProgress?.(uploadedBytes, totalSize);
851
+ throw new GislMultipartPartError(`S3 chunk upload failed for part ${part.partNumber} after ${this.multipartMaxAttempts} attempts: ` +
852
+ (lastErr instanceof Error ? lastErr.message : String(lastErr)), part.partNumber, initResponse.uploadId);
210
853
  };
211
- // Upload with concurrency limit
854
+ // Upload with concurrency limit. Workers check the signal before pulling
855
+ // the next queue item so a mid-upload abort drains fast without
856
+ // dispatching new chunks. Chunks already in-flight are cancelled via the
857
+ // composed signal passed to fetch above; sleeping siblings are woken via
858
+ // the failureController set below.
212
859
  const queue = [...presignedUrls.keys()];
213
860
  const workers = Array.from({ length: Math.min(this.multipartConcurrency, queue.length) }, async () => {
214
- while (queue.length > 0) {
861
+ while (queue.length > 0 && !failureController.signal.aborted) {
862
+ if (options?.signal?.aborted) {
863
+ throw new GislAbortError('Multipart upload aborted');
864
+ }
215
865
  const index = queue.shift();
216
- await uploadChunk(index);
866
+ try {
867
+ await uploadChunk(index);
868
+ }
869
+ catch (err) {
870
+ // Wake any sibling currently in a backoff sleep, and prevent
871
+ // siblings from picking up further queue items.
872
+ failureController.abort();
873
+ throw err;
874
+ }
217
875
  }
218
876
  });
219
877
  await Promise.all(workers);
220
878
  // Step 3: Complete multipart upload.
221
- etags.sort((a, b) => a.part_number - b.part_number);
879
+ // Build a typed MultipartCompleteRequest and serialise via the generated
880
+ // ToJSON helper — tsc now catches any field-name drift between the SDK
881
+ // and the OpenAPI spec (see contract-drift-fields.test.ts describe 'c').
882
+ etags.sort((a, b) => a.partNumber - b.partNumber);
883
+ const completeRequest = {
884
+ uploadId: initResponse.uploadId,
885
+ parts: etags,
886
+ };
887
+ // MultipartCompleteRequestToJSON's declared return type is the camelCase
888
+ // `MultipartCompleteRequest` interface, but at runtime it returns the
889
+ // snake_case wire object — an openapi-generator v7 quirk. The drift gate
890
+ // for field names lives at `completeRequest: MultipartCompleteRequest`
891
+ // above; the local wire type + runtime sanity check below guard against
892
+ // the remaining hypothetical: a future generator version emitting a
893
+ // different shape without the declared type catching it.
894
+ const wireCompleteBody = MultipartCompleteRequestToJSON(completeRequest);
895
+ if (typeof wireCompleteBody?.upload_id !== 'string' ||
896
+ !Array.isArray(wireCompleteBody?.parts)) {
897
+ throw new GislError('MultipartCompleteRequestToJSON returned an unexpected shape — generator output may have changed.');
898
+ }
222
899
  const completeResp = await this.request('POST', '/api/uploads/multipart/complete', {
223
- body: {
224
- upload_id: initResponse.uploadId,
225
- parts: etags,
226
- },
900
+ body: wireCompleteBody,
227
901
  deserialize: MultipartCompleteResponseFromJSON,
902
+ signal: options?.signal,
228
903
  });
229
904
  // Defensive: the status enum currently has only 'completed', but guard
230
905
  // against future expansion so an unexpected terminal state doesn't pass
@@ -236,7 +911,557 @@ export class GislClient {
236
911
  fileId: completeResp.uploadId,
237
912
  originalName: fileName,
238
913
  mimeType: initResponse.mimeType,
239
- sizeBytes: blob.size,
914
+ // `totalSize` (from fs.stat / Blob.size) — the streaming path no longer
915
+ // holds a whole-file Blob to read `.size` off.
916
+ sizeBytes: totalSize,
917
+ // Preserved from the initiate response: v2 contract makes
918
+ // `constraintsApplied` a REQUIRED field on UploadResponse, and the
919
+ // multipart/complete endpoint does not re-emit it. The first-chunk probe
920
+ // result on the initiate envelope is the authoritative source.
921
+ constraintsApplied: initResponse.constraintsApplied,
922
+ };
923
+ }
924
+ /**
925
+ * SDK-3 (Wb6ebOMM): resume an in-progress multipart upload.
926
+ *
927
+ * Skips `/multipart/initiate` entirely (the original initiate happened in a
928
+ * prior process). Walks `/status` for the authoritative list of recorded
929
+ * parts, re-presigns the missing ones in batches of <=100, PUTs only those,
930
+ * and finalises with `/complete`. Caller's `source` MUST be byte-identical
931
+ * to the originally-uploaded file at the same offsets (parts whose etags
932
+ * don't match server state will fail `/complete`).
933
+ *
934
+ * Re-runs the same `uploadId` / `chunkSize` / `totalParts` / plan-consistency
935
+ * guards as the fresh-upload path (`multipartUpload`), using the /status
936
+ * envelope as the equivalent of the initiate envelope. Reuses the same
937
+ * `failureController` sibling-wake + `drainResponseBody` cleanup discipline
938
+ * as the fresh-upload PUT loop. `onProgress` fires on entry seeded from
939
+ * (uploadedPartNumbers.length * chunkSize) and again after every successful
940
+ * PUT. `onCheckpoint` fires OUTSIDE the retry-scoped path after every
941
+ * successful PUT — a callback-throw must not trigger a duplicate PUT.
942
+ *
943
+ * TODO(HxUmVr3Y): replace inline hand-coded request body marshalling on regen.
944
+ */
945
+ async multipartResume(source, fileName, totalSize, resumeUploadId, options) {
946
+ // Step 1: Walk /status for the authoritative session state.
947
+ const status = await this.walkUploadStatus(resumeUploadId, {
948
+ signal: options?.signal,
949
+ });
950
+ // Validate the /status envelope shape, mirroring the fresh-upload
951
+ // post-initiate guards (`multipartUpload` lines around the
952
+ // total_parts / recommendedChunkSize / uploadId validation block).
953
+ if (typeof status.uploadId !== 'string' ||
954
+ status.uploadId === '' ||
955
+ status.uploadId !== resumeUploadId) {
956
+ throw new GislError('multipartResume: /status response uploadId does not match resumeUploadId.');
957
+ }
958
+ const chunkSize = status.recommendedChunkSize;
959
+ if (typeof chunkSize !== 'number' ||
960
+ !Number.isInteger(chunkSize) ||
961
+ chunkSize < MULTIPART_CHUNK_SIZE ||
962
+ chunkSize > RECOMMENDED_CHUNK_SIZE_MAX_BYTES) {
963
+ throw new GislError('multipartResume: /status recommendedChunkSize missing or outside the contract ' +
964
+ `range [${MULTIPART_CHUNK_SIZE}, ${RECOMMENDED_CHUNK_SIZE_MAX_BYTES}]: got ${String(chunkSize)}.`);
965
+ }
966
+ if (typeof status.totalParts !== 'number' ||
967
+ !Number.isInteger(status.totalParts) ||
968
+ status.totalParts < 1) {
969
+ throw new GislError(`multipartResume: /status totalParts missing or invalid: got ${String(status.totalParts)}.`);
970
+ }
971
+ if (status.totalParts > S3_MAX_MULTIPART_PARTS) {
972
+ throw new GislMultipartPartCountError(`multipartResume: /status totalParts=${status.totalParts} exceeds the S3 ` +
973
+ `${S3_MAX_MULTIPART_PARTS}-part multipart limit.`, status.totalParts, S3_MAX_MULTIPART_PARTS);
974
+ }
975
+ // Sanity-check the caller's byte source against the server's recorded
976
+ // plan. Mirrors the fresh-upload chunk-plan: part 1 = firstChunkSize
977
+ // (8 MiB), parts 2..totalParts each consume chunkSize bytes (last part
978
+ // may be a short tail). Reject a wrong-file resume here — /complete
979
+ // would otherwise fail on etag mismatch.
980
+ const firstChunkSize = Math.min(totalSize, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
981
+ const expectedMinBytes = firstChunkSize + Math.max(0, status.totalParts - 2) * chunkSize + (status.totalParts > 1 ? 1 : 0);
982
+ const expectedMaxBytes = firstChunkSize + Math.max(0, status.totalParts - 1) * chunkSize;
983
+ if (totalSize < expectedMinBytes || totalSize > expectedMaxBytes) {
984
+ throw new GislError(`multipartResume: caller file size (${totalSize}) does not match the resumed ` +
985
+ `session's recorded plan (totalParts=${status.totalParts}, chunkSize=${chunkSize}, ` +
986
+ `expected ${expectedMinBytes}-${expectedMaxBytes} bytes). Wrong file for this uploadId?`);
987
+ }
988
+ // Step 2: Compute missing parts. Server records `uploadedParts` as the
989
+ // authoritative set; everything in [1, totalParts] not in that set is
990
+ // still-to-upload. Part 1 was uploaded inline at initiate — if it is
991
+ // missing from /status the session is unrecoverable (the server rejects
992
+ // re-presigning part 1 to preserve the recorded etag for /complete).
993
+ const uploaded = new Map();
994
+ for (const p of status.uploadedParts) {
995
+ uploaded.set(p.partNumber, p);
996
+ }
997
+ if (!uploaded.has(1)) {
998
+ throw new GislError('multipartResume: part 1 (initiate first chunk) is missing from /status. ' +
999
+ 'Part 1 is sealed at initiate and cannot be re-presigned; this session is unrecoverable. ' +
1000
+ 'Start a fresh upload (call uploadFile without resumeUploadId).');
1001
+ }
1002
+ const missingParts = [];
1003
+ for (let n = 2; n <= status.totalParts; n++) {
1004
+ if (!uploaded.has(n))
1005
+ missingParts.push(n);
1006
+ }
1007
+ // Seed uploadedBytes from already-uploaded parts so onProgress reflects
1008
+ // the true resumption point. Server reports authoritative part sizes
1009
+ // via `sizeBytes`; sum those rather than guessing chunkSize * count
1010
+ // (the last part may be a short tail).
1011
+ let uploadedBytes = 0;
1012
+ for (const p of status.uploadedParts) {
1013
+ uploadedBytes += p.sizeBytes;
1014
+ }
1015
+ options?.onProgress?.(uploadedBytes, totalSize);
1016
+ const fireCheckpoint = (extraPartNumber) => {
1017
+ const all = [...uploaded.keys()];
1018
+ if (extraPartNumber !== undefined)
1019
+ all.push(extraPartNumber);
1020
+ all.sort((a, b) => a - b);
1021
+ const state = {
1022
+ uploadId: status.uploadId,
1023
+ totalParts: status.totalParts,
1024
+ uploadedPartNumbers: all,
1025
+ manifestExpiresAt: status.manifestExpiresAt,
1026
+ };
1027
+ // Callback fires OUTSIDE retry-scope. A throw here propagates and
1028
+ // fails the upload but cannot trigger a duplicate PUT.
1029
+ options?.onCheckpoint?.(state);
1030
+ };
1031
+ // Fire an entry checkpoint so callers can persist the resumed state
1032
+ // even before any new PUT lands. Useful when the missing-parts list is
1033
+ // empty (everything already uploaded except /complete) — see below.
1034
+ fireCheckpoint();
1035
+ // Short-circuit: every part is already uploaded. Skip presign + PUT
1036
+ // and go straight to /complete with the etags the server has on file.
1037
+ const newEtags = [];
1038
+ if (missingParts.length === 0) {
1039
+ // No PUTs to run; proceed to /complete below with just the recorded parts.
1040
+ }
1041
+ else {
1042
+ // Step 3: For each batch of <=100 missing parts, re-presign + PUT.
1043
+ // We process batches sequentially (presign call) but PUTs within each
1044
+ // batch run concurrently up to multipartConcurrency, mirroring the
1045
+ // fresh-upload worker-pool semantics.
1046
+ const failureController = new AbortController();
1047
+ const putOne = async (part) => {
1048
+ // Offset math mirrors the fresh-upload path
1049
+ // (`multipartUpload`'s `uploadChunk`): part 1 is the initiate's 8 MiB
1050
+ // first chunk, parts 2..N each consume chunkSize bytes starting at
1051
+ // firstChunkSize. The resume path never PUTs part 1 (rejected
1052
+ // earlier as unrecoverable), so partNumber here is always >= 2.
1053
+ const firstChunkSize = Math.min(totalSize, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
1054
+ const start = firstChunkSize + (part.partNumber - 2) * chunkSize;
1055
+ const end = Math.min(start + chunkSize, totalSize);
1056
+ const contentLength = end - start;
1057
+ let chunk;
1058
+ try {
1059
+ chunk = await source.slice(start, end);
1060
+ }
1061
+ catch (err) {
1062
+ if (err instanceof GislAbortError)
1063
+ throw err;
1064
+ throw new GislMultipartPartError(`multipartResume: failed to read bytes for part ${part.partNumber}: ` +
1065
+ (err instanceof Error ? err.message : String(err)), part.partNumber, status.uploadId);
1066
+ }
1067
+ let lastErr = null;
1068
+ for (let attempt = 0; attempt < this.multipartMaxAttempts; attempt++) {
1069
+ if (options?.signal?.aborted) {
1070
+ throw new GislAbortError(`multipartResume: S3 part ${part.partNumber} upload aborted`);
1071
+ }
1072
+ if (failureController.signal.aborted) {
1073
+ throw new GislError(`multipartResume: S3 part ${part.partNumber} upload abandoned after sibling failure`);
1074
+ }
1075
+ let s3Response;
1076
+ try {
1077
+ s3Response = await fetch(part.url, {
1078
+ method: 'PUT',
1079
+ body: chunk,
1080
+ headers: { 'Content-Length': contentLength.toString() },
1081
+ signal: options?.signal,
1082
+ });
1083
+ }
1084
+ catch (err) {
1085
+ if (isAbortError(err) && options?.signal?.aborted) {
1086
+ throw new GislAbortError(`multipartResume: S3 part ${part.partNumber} upload aborted`);
1087
+ }
1088
+ // Non-user-abort AbortError (e.g. transport cleanup) must still
1089
+ // surface as a typed GislError subclass — never as a raw
1090
+ // DOMException — to preserve the "every multipart failure is a
1091
+ // typed GislError" contract (code-reviewer P7).
1092
+ if (isAbortError(err)) {
1093
+ throw new GislMultipartPartError(`multipartResume: S3 part ${part.partNumber} aborted by transport: ` +
1094
+ (err instanceof Error ? err.message : String(err)), part.partNumber, status.uploadId);
1095
+ }
1096
+ if (isRetryableNetworkError(err)) {
1097
+ lastErr = err;
1098
+ if (attempt + 1 >= this.multipartMaxAttempts)
1099
+ break;
1100
+ const delay = fullJitterDelay(this.multipartRetryBaseMs, attempt);
1101
+ await sleepWithEitherSignal(delay, options?.signal, failureController.signal);
1102
+ continue;
1103
+ }
1104
+ throw err;
1105
+ }
1106
+ if (s3Response.ok) {
1107
+ const etag = s3Response.headers.get('etag');
1108
+ if (!etag) {
1109
+ await drainResponseBody(s3Response);
1110
+ throw new GislError(`multipartResume: S3 response missing ETag for part ${part.partNumber}`);
1111
+ }
1112
+ // Successful PUT — record etag, apply progress + checkpoint side
1113
+ // effects OUTSIDE the retry-scoped path (mirrors the fresh-upload
1114
+ // discipline at multipartUpload's ok-branch).
1115
+ newEtags.push({ partNumber: part.partNumber, etag });
1116
+ uploaded.set(part.partNumber, {
1117
+ partNumber: part.partNumber,
1118
+ etag,
1119
+ sizeBytes: contentLength,
1120
+ lastModified: new Date().toISOString(),
1121
+ });
1122
+ uploadedBytes = Math.min(uploadedBytes + contentLength, totalSize);
1123
+ options?.onProgress?.(uploadedBytes, totalSize);
1124
+ fireCheckpoint();
1125
+ return;
1126
+ }
1127
+ await drainResponseBody(s3Response);
1128
+ if (!isRetryableStatus(s3Response.status)) {
1129
+ throw new GislError(`multipartResume: S3 chunk upload failed for part ${part.partNumber}: HTTP ${s3Response.status} (non-retryable)`);
1130
+ }
1131
+ lastErr = new GislError(`multipartResume: S3 chunk upload failed for part ${part.partNumber}: HTTP ${s3Response.status}`);
1132
+ if (attempt + 1 >= this.multipartMaxAttempts)
1133
+ break;
1134
+ const delay = fullJitterDelay(this.multipartRetryBaseMs, attempt);
1135
+ await sleepWithEitherSignal(delay, options?.signal, failureController.signal);
1136
+ }
1137
+ throw new GislMultipartPartError(`multipartResume: S3 chunk upload failed for part ${part.partNumber} after ${this.multipartMaxAttempts} attempts: ` +
1138
+ (lastErr instanceof Error ? lastErr.message : String(lastErr)), part.partNumber, status.uploadId);
1139
+ };
1140
+ // Drive batches of <=100 part numbers.
1141
+ const PRESIGN_BATCH_SIZE = 100;
1142
+ for (let i = 0; i < missingParts.length; i += PRESIGN_BATCH_SIZE) {
1143
+ if (options?.signal?.aborted) {
1144
+ throw new GislAbortError('multipartResume aborted');
1145
+ }
1146
+ const batch = missingParts.slice(i, i + PRESIGN_BATCH_SIZE);
1147
+ const presigned = await this.presignParts(status.uploadId, batch, status.totalParts, { signal: options?.signal });
1148
+ // Concurrent PUTs within the batch.
1149
+ const queue = [...presigned.presignedUrls];
1150
+ const workers = Array.from({ length: Math.min(this.multipartConcurrency, queue.length) }, async () => {
1151
+ while (queue.length > 0 && !failureController.signal.aborted) {
1152
+ if (options?.signal?.aborted) {
1153
+ throw new GislAbortError('multipartResume aborted');
1154
+ }
1155
+ const part = queue.shift();
1156
+ try {
1157
+ await putOne(part);
1158
+ }
1159
+ catch (err) {
1160
+ failureController.abort();
1161
+ throw err;
1162
+ }
1163
+ }
1164
+ });
1165
+ await Promise.all(workers);
1166
+ }
1167
+ }
1168
+ // Step 4: /complete with the FULL parts list = (server-recorded etags
1169
+ // from /status) ∪ (newly-PUT etags this run). Sort ascending by
1170
+ // partNumber (the wire shape pin in fresh-upload mirrors this).
1171
+ const allParts = [];
1172
+ for (const p of status.uploadedParts) {
1173
+ allParts.push({ partNumber: p.partNumber, etag: p.etag });
1174
+ }
1175
+ for (const e of newEtags)
1176
+ allParts.push(e);
1177
+ allParts.sort((a, b) => a.partNumber - b.partNumber);
1178
+ if (allParts.length !== status.totalParts) {
1179
+ throw new GislError(`multipartResume: assembled parts list has ${allParts.length} entries, ` +
1180
+ `expected ${status.totalParts}. Refusing to /complete with an incomplete part set.`);
1181
+ }
1182
+ // Marshal via the generator's `*ToJSON` helper so the contracts-drift
1183
+ // guard test (`contract-drift-fields.test.ts`) covers BOTH the fresh and
1184
+ // resume paths uniformly (code-reviewer P7). If a future regen adds a
1185
+ // required field to `MultipartCompleteRequest`, tsc fails here at the
1186
+ // typed object literal — same as the fresh path.
1187
+ const completeRequest = {
1188
+ uploadId: status.uploadId,
1189
+ parts: allParts.map((p) => ({ partNumber: p.partNumber, etag: p.etag })),
1190
+ };
1191
+ const wireCompleteBody = MultipartCompleteRequestToJSON(completeRequest);
1192
+ if (typeof wireCompleteBody?.upload_id !== 'string' ||
1193
+ !Array.isArray(wireCompleteBody?.parts)) {
1194
+ throw new GislError('multipartResume: MultipartCompleteRequestToJSON returned an unexpected shape.');
1195
+ }
1196
+ const completeResp = await this.request('POST', '/api/uploads/multipart/complete', {
1197
+ body: wireCompleteBody,
1198
+ deserialize: MultipartCompleteResponseFromJSON,
1199
+ signal: options?.signal,
1200
+ });
1201
+ if (completeResp.status !== 'completed') {
1202
+ throw new GislError(`multipartResume: completed with unexpected status: ${completeResp.status}`);
1203
+ }
1204
+ // Resume-path information loss: the /status envelope (and /complete)
1205
+ // do NOT carry `mime_type` or `constraints_applied` — those were
1206
+ // emitted on the original initiate envelope, which the resume path
1207
+ // skipped. Fall back to caller-supplied `fileName` for `originalName`;
1208
+ // emit `mimeType` as `''` and `constraintsApplied` as a sentinel
1209
+ // populated with the only fact we DO know on resume: `maxSizeBytes =
1210
+ // totalSize` (the upload was permitted at this size when initiated),
1211
+ // `processingClassPreAssignment = 'unknown'`. Consumers needing
1212
+ // authoritative post-upload metadata SHOULD call `getMetadata(fileId)`
1213
+ // (the fresh-upload path's docblock already says the same).
1214
+ // TODO(HxUmVr3Y): when contracts ships the resume-support schemas,
1215
+ // extend `/status` (or add `/multipart/{id}/manifest`) to carry
1216
+ // mime_type + constraints_applied so this sentinel can go away.
1217
+ return {
1218
+ fileId: completeResp.uploadId,
1219
+ originalName: fileName,
1220
+ mimeType: '',
1221
+ sizeBytes: totalSize,
1222
+ constraintsApplied: {
1223
+ maxSizeBytes: totalSize,
1224
+ // `maxDurationSeconds` deliberately omitted (not `null`): parity
1225
+ // comparator filters `undefined` keys from both sides; cross-SDK
1226
+ // upload_small precedent.
1227
+ processingClassPreAssignment: UploadConstraintsAppliedProcessingClassPreAssignmentEnum.unknown,
1228
+ },
1229
+ };
1230
+ }
1231
+ // -----------------------------------------------------------------------
1232
+ // SDK-3 (Wb6ebOMM) — resume-support endpoints
1233
+ // -----------------------------------------------------------------------
1234
+ /**
1235
+ * Fetch the durable status of an in-progress multipart upload session.
1236
+ *
1237
+ * Walks every page of `GET /api/uploads/multipart/{uploadId}/status`
1238
+ * (paginated via `next_part_number_marker` + `is_truncated`) and returns
1239
+ * the aggregated state. Callers see the complete set of recorded parts
1240
+ * across pages without driving the cursor themselves.
1241
+ *
1242
+ * Anonymous-initiated sessions return 403 → `GislMultipartSessionAuthRequiredError`.
1243
+ * Non-existent / expired sessions return 404 → `GislMultipartSessionNotFoundError`.
1244
+ * Authed-but-non-owning callers return 403 → `GislMultipartSessionOwnershipError`.
1245
+ *
1246
+ * TODO(HxUmVr3Y): replace hand-coded response shape on regen.
1247
+ */
1248
+ async getUploadStatus(uploadId, opts = {}) {
1249
+ if (typeof uploadId !== 'string' || uploadId === '') {
1250
+ throw new GislError('getUploadStatus: uploadId must be a non-empty string.');
1251
+ }
1252
+ return this.walkUploadStatus(uploadId, opts);
1253
+ }
1254
+ /**
1255
+ * Re-presign a batch of missing part numbers on an in-progress multipart
1256
+ * session.
1257
+ *
1258
+ * Validates client-side BEFORE the HTTP round-trip:
1259
+ * - `partNumbers` non-empty
1260
+ * - length <=100 (server raw-body cap is 8 KiB before json_decode)
1261
+ * - every entry an integer in `[2, totalParts]` — part 1 is sealed at
1262
+ * initiate (re-presigning it would break the etag recorded server-side
1263
+ * for /complete)
1264
+ * - entries unique
1265
+ * - `totalParts` <=10 000 (S3 hard limit; mirrors the SDK-1 ceiling guard)
1266
+ *
1267
+ * TODO(HxUmVr3Y): replace hand-coded request/response shapes on regen.
1268
+ */
1269
+ async presignParts(uploadId, partNumbers, totalParts, opts = {}) {
1270
+ if (typeof uploadId !== 'string' || uploadId === '') {
1271
+ throw new GislError('presignParts: uploadId must be a non-empty string.');
1272
+ }
1273
+ if (typeof totalParts !== 'number' ||
1274
+ !Number.isInteger(totalParts) ||
1275
+ totalParts < 1) {
1276
+ throw new GislError(`presignParts: totalParts must be a positive integer, got ${String(totalParts)}.`);
1277
+ }
1278
+ if (totalParts > S3_MAX_MULTIPART_PARTS) {
1279
+ throw new GislMultipartPartCountError(`presignParts: totalParts=${totalParts} exceeds the S3 ${S3_MAX_MULTIPART_PARTS}-part ` +
1280
+ 'multipart limit. Refusing to re-presign on a session that cannot complete.', totalParts, S3_MAX_MULTIPART_PARTS);
1281
+ }
1282
+ if (!Array.isArray(partNumbers) || partNumbers.length === 0) {
1283
+ throw new GislError('presignParts: partNumbers must be a non-empty array.');
1284
+ }
1285
+ if (partNumbers.length > 100) {
1286
+ throw new GislError(`presignParts: partNumbers has ${partNumbers.length} entries — server caps batches at 100.`);
1287
+ }
1288
+ const seen = new Set();
1289
+ for (const n of partNumbers) {
1290
+ if (typeof n !== 'number' ||
1291
+ !Number.isInteger(n) ||
1292
+ n < 2 ||
1293
+ n > totalParts) {
1294
+ throw new GislError(`presignParts: partNumbers entry ${String(n)} is not an integer in [2, ${totalParts}]. ` +
1295
+ 'Part 1 is sealed at initiate; re-presigning it would invalidate the recorded etag for /complete.');
1296
+ }
1297
+ if (seen.has(n)) {
1298
+ throw new GislError(`presignParts: partNumbers contains duplicate ${n}.`);
1299
+ }
1300
+ seen.add(n);
1301
+ }
1302
+ const path = `/api/uploads/multipart/${encodeURIComponent(uploadId)}/presign`;
1303
+ return this.request('POST', path, {
1304
+ // Hand-coded snake_case wire body. TODO(HxUmVr3Y): replace with
1305
+ // generated `*RequestToJSON` helper on regen.
1306
+ body: { part_numbers: [...partNumbers] },
1307
+ deserialize: (raw) => {
1308
+ // Hand-coded snake_case -> camelCase. TODO(HxUmVr3Y): replace with
1309
+ // generated FromJSON helper on regen.
1310
+ const r = raw;
1311
+ if (typeof r.upload_id !== 'string' || !Array.isArray(r.presigned_urls)) {
1312
+ throw new GislError('presignParts: malformed response envelope.');
1313
+ }
1314
+ return {
1315
+ uploadId: r.upload_id,
1316
+ presignedUrls: r.presigned_urls.map((p) => ({
1317
+ partNumber: p.part_number,
1318
+ url: p.url,
1319
+ expiresAt: p.expires_at,
1320
+ })),
1321
+ };
1322
+ },
1323
+ signal: opts.signal,
1324
+ });
1325
+ }
1326
+ /**
1327
+ * Extend the manifest TTL of an in-progress multipart upload session.
1328
+ *
1329
+ * The durable session manifest defaults to a 48 h TTL (decoupled from the
1330
+ * shorter presigned-URL TTL). For a long-running resume that spans days
1331
+ * (e.g. an upload paused overnight on flaky Wi-Fi), callers SHOULD invoke
1332
+ * `keepaliveUpload` every **12-24 h** while resuming — the 12-24 h band
1333
+ * leaves >=24 h of slack against the 48 h ceiling even with worst-case
1334
+ * clock skew between client and server. The server atomically refreshes
1335
+ * the Redis EXPIRE for the manifest key; the call is idempotent.
1336
+ *
1337
+ * TODO(HxUmVr3Y): replace hand-coded response shape on regen.
1338
+ */
1339
+ async keepaliveUpload(uploadId, opts = {}) {
1340
+ if (typeof uploadId !== 'string' || uploadId === '') {
1341
+ throw new GislError('keepaliveUpload: uploadId must be a non-empty string.');
1342
+ }
1343
+ const path = `/api/uploads/multipart/${encodeURIComponent(uploadId)}/keepalive`;
1344
+ return this.request('POST', path, {
1345
+ // Server expects an empty body; pass an empty object so the `request`
1346
+ // helper sets `Content-Type: application/json` for symmetry with the
1347
+ // other JSON-bodied POSTs. The endpoint ignores any fields if present.
1348
+ body: {},
1349
+ deserialize: (raw) => {
1350
+ // Hand-coded snake_case -> camelCase. TODO(HxUmVr3Y): replace with
1351
+ // generated FromJSON helper on regen.
1352
+ const r = raw;
1353
+ if (typeof r.upload_id !== 'string' ||
1354
+ typeof r.manifest_expires_at !== 'string') {
1355
+ throw new GislError('keepaliveUpload: malformed response envelope.');
1356
+ }
1357
+ return {
1358
+ uploadId: r.upload_id,
1359
+ manifestExpiresAt: r.manifest_expires_at,
1360
+ };
1361
+ },
1362
+ signal: opts.signal,
1363
+ });
1364
+ }
1365
+ /**
1366
+ * Private walk-pagination helper for /status. Aggregates every page into
1367
+ * a single `_Sdk3HandCodedMultipartStatusResult`. AbortSignal short-circuits
1368
+ * the loop between page fetches AND propagates into each fetch.
1369
+ *
1370
+ * Limit pinned to 1000 (max per page) so we make the minimum number of
1371
+ * round-trips even for the worst-case ~10 pages on a 10 000-part upload.
1372
+ */
1373
+ async walkUploadStatus(uploadId, opts) {
1374
+ const PAGE_LIMIT = 1000;
1375
+ // Slow-path DoS guard (code-reviewer minor 6). The cursor-advance check
1376
+ // already prevents an infinite loop; this cap additionally prevents a
1377
+ // pathological server that advances by 1 each page from forcing
1378
+ // O(totalParts) round-trips for a 10 000-part upload. PAGE_LIMIT=1000
1379
+ // means a healthy server completes in <=10 round-trips; 50 leaves
1380
+ // generous slack.
1381
+ const MAX_PAGES = 50;
1382
+ const collected = [];
1383
+ let cursor = 0;
1384
+ let totalParts = 0;
1385
+ let multipartUploadId = '';
1386
+ let cloudKey = '';
1387
+ let manifestExpiresAt = '';
1388
+ let recommendedChunkSize = 0;
1389
+ let pageCount = 0;
1390
+ while (true) {
1391
+ if (opts.signal?.aborted) {
1392
+ throw new GislAbortError('getUploadStatus aborted');
1393
+ }
1394
+ if (pageCount >= MAX_PAGES) {
1395
+ throw new GislError(`getUploadStatus: server returned more than ${MAX_PAGES} pages — refusing to ` +
1396
+ 'continue. The /status endpoint should advance the cursor in 1000-part strides.');
1397
+ }
1398
+ pageCount += 1;
1399
+ const query = `?cursor=${cursor}&limit=${PAGE_LIMIT}`;
1400
+ // String-concat the query OUTSIDE the path backtick so the contract-drift
1401
+ // scanner (tests/unit/contract-drift.test.ts) sees the bare path
1402
+ // `/api/uploads/multipart/{id}/status`. Embedding `${query}` in the
1403
+ // template collapses to `/status{id}` and false-drifts — same reason
1404
+ // getSchema and getCreditsUsage concatenate their querystrings.
1405
+ const path = `/api/uploads/multipart/${encodeURIComponent(uploadId)}/status` + query;
1406
+ const page = await this.request('GET', path, { signal: opts.signal });
1407
+ // Defensive: server contract pins these fields. Strict-validate every
1408
+ // top-level field on each page (code-reviewer P7) so a malformed wire
1409
+ // envelope cannot silently coerce a missing key to '' / 0 / NaN and
1410
+ // flow it into MultipartCheckpointState.manifestExpiresAt or downstream
1411
+ // chunkSize guards.
1412
+ if (typeof page.total_parts !== 'number' || page.total_parts < 1) {
1413
+ throw new GislError('getUploadStatus: server page missing or invalid total_parts.');
1414
+ }
1415
+ if (typeof page.upload_id !== 'string' ||
1416
+ page.upload_id !== uploadId ||
1417
+ typeof page.multipart_upload_id !== 'string' ||
1418
+ page.multipart_upload_id === '' ||
1419
+ typeof page.cloud_key !== 'string' ||
1420
+ page.cloud_key === '' ||
1421
+ typeof page.manifest_expires_at !== 'string' ||
1422
+ page.manifest_expires_at === '' ||
1423
+ typeof page.recommended_chunk_size !== 'number') {
1424
+ throw new GislError('getUploadStatus: server page missing required fields or returned a ' +
1425
+ `mismatching upload_id (expected ${uploadId}, got ` +
1426
+ `${String(page.upload_id)}).`);
1427
+ }
1428
+ totalParts = page.total_parts;
1429
+ multipartUploadId = page.multipart_upload_id;
1430
+ cloudKey = page.cloud_key;
1431
+ manifestExpiresAt = page.manifest_expires_at;
1432
+ recommendedChunkSize = page.recommended_chunk_size;
1433
+ for (const p of page.uploaded_parts ?? []) {
1434
+ collected.push({
1435
+ partNumber: p.part_number,
1436
+ etag: p.etag,
1437
+ sizeBytes: p.size_bytes,
1438
+ lastModified: p.last_modified,
1439
+ });
1440
+ }
1441
+ if (!page.is_truncated)
1442
+ break;
1443
+ // Advance cursor; guard against a contract-violating non-advancing
1444
+ // marker that would loop forever.
1445
+ if (typeof page.next_part_number_marker !== 'number' ||
1446
+ page.next_part_number_marker <= cursor) {
1447
+ throw new GislError('getUploadStatus: server is_truncated=true but next_part_number_marker ' +
1448
+ `did not advance (was ${cursor}, got ${String(page.next_part_number_marker)}).`);
1449
+ }
1450
+ cursor = page.next_part_number_marker;
1451
+ }
1452
+ // Sort ascending by partNumber — server SHOULD already deliver in order
1453
+ // page-by-page, but a defensive sort keeps the aggregated shape's
1454
+ // contract simple to consume (resume-branch missing-parts compute scans
1455
+ // it linearly).
1456
+ collected.sort((a, b) => a.partNumber - b.partNumber);
1457
+ return {
1458
+ uploadId,
1459
+ multipartUploadId,
1460
+ cloudKey,
1461
+ totalParts,
1462
+ uploadedParts: collected,
1463
+ manifestExpiresAt,
1464
+ recommendedChunkSize,
240
1465
  };
241
1466
  }
242
1467
  // -----------------------------------------------------------------------
@@ -278,6 +1503,49 @@ export class GislClient {
278
1503
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
279
1504
  }
280
1505
  }
1506
+ /**
1507
+ * Cancel a workflow. Idempotent — cancelling an already-cancelled
1508
+ * workflow returns 200 with the same shape (and the original
1509
+ * `cancelledAt`). Cancelling a `completed` / `failed` /
1510
+ * `partially_failed` / `expired` workflow returns 409.
1511
+ *
1512
+ * The response's `billingEffect` field tells the caller what
1513
+ * happened to outstanding reservations:
1514
+ * - `unspent_reservation_released` — workflow was active or paused
1515
+ * and the unspent portion of the reservation has been refunded.
1516
+ * The refund appears as a separate `CreditTransaction` with
1517
+ * `type: refund`.
1518
+ * - `none` — no refund (all reserved credits were already consumed
1519
+ * by completed jobs, or this is an idempotent re-cancel).
1520
+ *
1521
+ * In-flight operations may continue running briefly after the
1522
+ * cancel response while their Lambda processes terminate; the
1523
+ * response is the binding "no further reservations will be made"
1524
+ * signal.
1525
+ */
1526
+ async cancelWorkflow(workflowId) {
1527
+ return this.request('POST', `/api/workflows/${encodeURIComponent(workflowId)}/cancel`, {
1528
+ deserialize: WorkflowCancelResponseFromJSON,
1529
+ });
1530
+ }
1531
+ /**
1532
+ * Resume a workflow that is in `paused_insufficient_credits`.
1533
+ *
1534
+ * Resume succeeds only when `availableCredits` covers the next
1535
+ * reservation. If the balance is still insufficient, throws
1536
+ * `GislBalanceExhaustedError` (402, same envelope as the workflow-
1537
+ * create 402 path) and the workflow stays paused. If the workflow
1538
+ * is past its `expiresAt` (default 7-day TTL from `pausedAt`),
1539
+ * throws `GislWorkflowExpiredError` (422) and the workflow has
1540
+ * transitioned to `expired` — callers cannot un-expire a workflow.
1541
+ * Resuming a workflow that is not in `paused_insufficient_credits`
1542
+ * is a 409 (no-op).
1543
+ */
1544
+ async resumeWorkflow(workflowId) {
1545
+ return this.request('POST', `/api/workflows/${encodeURIComponent(workflowId)}/resume`, {
1546
+ deserialize: WorkflowResumeResponseFromJSON,
1547
+ });
1548
+ }
281
1549
  /**
282
1550
  * Get download URLs for a completed workflow.
283
1551
  */
@@ -289,13 +1557,105 @@ export class GislClient {
289
1557
  /**
290
1558
  * Stream SSE events for a workflow. Returns an async iterable.
291
1559
  */
292
- async streamEvents(workflowId) {
1560
+ async streamEvents(workflowId, opts = {}) {
293
1561
  const eventsPath = `/api/workflows/${encodeURIComponent(workflowId)}/events`;
294
- const response = await this.request('GET', eventsPath, { rawResponse: true });
1562
+ // SSE-lifetime AbortController. `request()` builds its own controller
1563
+ // and tears it down (`clearTimeout(timer); unbind()`) in its `finally`
1564
+ // the instant the response headers arrive — BEFORE the SSE body
1565
+ // streams — so that controller cannot cancel a long-lived stream.
1566
+ // `streamEvents` must own a controller for the stream's whole lifetime.
1567
+ // We pass its signal to `request()` too, so a pre-aborted signal /
1568
+ // connect-phase abort still fast-fails. After headers, the live socket
1569
+ // is freed only by `reader.cancel()` inside `parseSseStream` — driven
1570
+ // by aborting this controller from the iterator wrapper's
1571
+ // `return()`/`throw()` (a generator's own `return()` is unreachable
1572
+ // while suspended at `await reader.read()`; canonical pattern:
1573
+ // openai-node `Stream[Symbol.asyncIterator]` + PR #1314).
1574
+ const controller = new AbortController();
1575
+ // Compose an optional consumer-supplied signal onto our controller.
1576
+ // The teardown MUST run (normal completion, error, OR early return)
1577
+ // or a long-lived consumer AbortController leaks listeners.
1578
+ const releaseConsumerSignal = bindAbortSignal(opts.signal, controller);
1579
+ let response;
1580
+ try {
1581
+ response = await this.request('GET', eventsPath, {
1582
+ rawResponse: true,
1583
+ signal: controller.signal,
1584
+ });
1585
+ }
1586
+ catch (err) {
1587
+ releaseConsumerSignal();
1588
+ throw err;
1589
+ }
295
1590
  if (!response.ok) {
296
- await this.handleResponse(response, eventsPath);
1591
+ try {
1592
+ await this.handleResponse(response, eventsPath); // always throws
1593
+ }
1594
+ finally {
1595
+ releaseConsumerSignal();
1596
+ }
297
1597
  }
298
- return parseSseStream(response);
1598
+ const inner = parseSseStream(response, { signal: controller.signal });
1599
+ let started = false;
1600
+ let settled = false;
1601
+ // Idempotent teardown. `abort` only on consumer-driven early
1602
+ // termination (return/throw) — NOT on normal completion or stream
1603
+ // error, where aborting would be a spurious "aborted though it
1604
+ // wasn't" signal (openai-node#194). If the consumer disposes the
1605
+ // iterator before ever pulling an event, the inner generator never
1606
+ // ran, so its `finally` won't cancel the body — cancel it here as a
1607
+ // backstop (the body is still unlocked: no reader was acquired).
1608
+ const cleanup = (abort) => {
1609
+ if (settled)
1610
+ return;
1611
+ settled = true;
1612
+ if (abort)
1613
+ controller.abort();
1614
+ if (!started)
1615
+ void response.body?.cancel().catch(() => { });
1616
+ releaseConsumerSignal();
1617
+ };
1618
+ // Abort-before-first-pull backstop. If the consumer aborts (their
1619
+ // signal, composed onto `controller`) and then drops the iterator
1620
+ // WITHOUT ever calling next()/return()/throw(), nothing else frees the
1621
+ // already-fetched body: `request()` unbound its fetch controller at
1622
+ // header receipt, and `parseSseStream` only attaches its reader +
1623
+ // abort listener once iteration starts. `cleanup`'s `!started` branch
1624
+ // only runs from the wrapper methods, so it never fires on a pure
1625
+ // abort-and-drop. Cancel the (still-unlocked) body directly here.
1626
+ // Once started, `parseSseStream` owns the locked reader and cancels
1627
+ // via its own abort listener, so this no-ops.
1628
+ controller.signal.addEventListener('abort', () => {
1629
+ if (!started)
1630
+ void response.body?.cancel().catch(() => { });
1631
+ }, { once: true });
1632
+ const wrapper = {
1633
+ async next(...args) {
1634
+ started = true;
1635
+ try {
1636
+ const result = await inner.next(...args);
1637
+ if (result.done)
1638
+ cleanup(false);
1639
+ return result;
1640
+ }
1641
+ catch (err) {
1642
+ cleanup(false);
1643
+ throw err;
1644
+ }
1645
+ },
1646
+ async return(value) {
1647
+ cleanup(true);
1648
+ return inner.return(value);
1649
+ },
1650
+ async throw(err) {
1651
+ cleanup(true);
1652
+ return inner.throw(err);
1653
+ },
1654
+ [Symbol.asyncIterator]() {
1655
+ return this;
1656
+ },
1657
+ };
1658
+ return wrapper;
299
1659
  }
300
1660
  // -----------------------------------------------------------------------
301
1661
  // File metadata
@@ -313,12 +1673,58 @@ export class GislClient {
313
1673
  // -----------------------------------------------------------------------
314
1674
  /**
315
1675
  * Get the operations schema (available types, options, constraints).
316
- * This endpoint returns raw JSON (no envelope) and is CDN-cacheable.
1676
+ *
1677
+ * Returns raw JSON (no envelope). The response is **per-tier private**
1678
+ * (cache key includes the caller's `user_tier`); CDN-style public
1679
+ * caching is not used. Pass `ifNoneMatch` / `ifModifiedSince` from a
1680
+ * previous response to revalidate — a 304 surfaces as
1681
+ * `{ notModified: true, etag, lastModified }` so callers can keep
1682
+ * using their cached copy.
317
1683
  */
318
- async getSchema() {
319
- return this.request('GET', '/api/operations/schema', {
320
- deserialize: OperationsSchemaResponseFromJSON,
1684
+ async getSchema(options = {}) {
1685
+ const params = new URLSearchParams();
1686
+ if (options.mimeType !== undefined)
1687
+ params.set('mime_type', options.mimeType);
1688
+ if (options.operation !== undefined)
1689
+ params.set('operation', options.operation);
1690
+ const query = params.toString();
1691
+ // The contract-drift test (tests/unit/contract-drift.test.ts) scans this
1692
+ // file for path literals via a regex that picks up both single-quoted
1693
+ // strings AND backtick templates. Embedding the querystring in a single
1694
+ // template would normalise to a path-with-querystring that no contract
1695
+ // path matches. Compose with concatenation so only the bare path appears
1696
+ // as a literal.
1697
+ const path = '/api/operations/schema' + (query ? '?' + query : '');
1698
+ const headers = {};
1699
+ if (options.ifNoneMatch !== undefined)
1700
+ headers['If-None-Match'] = options.ifNoneMatch;
1701
+ if (options.ifModifiedSince !== undefined)
1702
+ headers['If-Modified-Since'] = options.ifModifiedSince;
1703
+ const response = await this.request('GET', path, {
1704
+ rawResponse: true,
1705
+ signal: options.signal,
1706
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
321
1707
  });
1708
+ const etag = response.headers.get('etag') ?? undefined;
1709
+ const lastModified = response.headers.get('last-modified') ?? undefined;
1710
+ if (response.status === 304) {
1711
+ return { notModified: true, etag, lastModified };
1712
+ }
1713
+ if (!response.ok) {
1714
+ let errorMessage = 'Unknown error';
1715
+ try {
1716
+ const errJson = (await response.json());
1717
+ if (errJson.error)
1718
+ errorMessage = errJson.error;
1719
+ }
1720
+ catch {
1721
+ // Non-JSON body — keep generic message.
1722
+ }
1723
+ throw new GislApiError(response.status, errorMessage, path);
1724
+ }
1725
+ const raw = await response.json();
1726
+ const data = OperationsSchemaResponseFromJSON(raw);
1727
+ return { notModified: false, data, etag, lastModified };
322
1728
  }
323
1729
  /**
324
1730
  * Retry a failed operation.
@@ -328,4 +1734,213 @@ export class GislClient {
328
1734
  deserialize: RetryResponseFromJSON,
329
1735
  });
330
1736
  }
1737
+ // -----------------------------------------------------------------------
1738
+ // Contact
1739
+ // -----------------------------------------------------------------------
1740
+ /**
1741
+ * Submit a contact-form message. The endpoint returns 204 No Content on
1742
+ * success, so this method resolves to `void`.
1743
+ *
1744
+ * Validation errors (e.g. missing `email`, non-empty honeypot `website`)
1745
+ * surface as `GislValidationError` from the standard error envelope.
1746
+ */
1747
+ async submitContact(payload) {
1748
+ await this.request('POST', '/api/contact', {
1749
+ body: payload,
1750
+ });
1751
+ }
1752
+ // -----------------------------------------------------------------------
1753
+ // Credits / billing
1754
+ // -----------------------------------------------------------------------
1755
+ /**
1756
+ * Get a snapshot of the caller's current credit position. The canonical
1757
+ * billing-state surface — `BalanceExhaustedResponse` (402) on workflow
1758
+ * creation includes pre-error counters for context, but UIs should drive
1759
+ * spend-now affordances and tier-upgrade prompts off this endpoint, not
1760
+ * off the error envelope.
1761
+ */
1762
+ async getCreditsBalance() {
1763
+ return this.request('GET', '/api/v2/credits/balance', {
1764
+ deserialize: CreditsBalanceResponseFromJSON,
1765
+ });
1766
+ }
1767
+ // -----------------------------------------------------------------------
1768
+ // Auth
1769
+ // -----------------------------------------------------------------------
1770
+ /**
1771
+ * Authenticate with email/password. On success the server issues a
1772
+ * session cookie via `Set-Cookie`; subsequent requests authenticate
1773
+ * via that cookie when the client is configured with
1774
+ * `useSessionCookie: true`.
1775
+ *
1776
+ * Failure modes per ticket FX6mbTJD:
1777
+ * - **401** `invalid_credentials` (collapsed with unverified
1778
+ * accounts for anti-enumeration) → `GislAuthError`.
1779
+ * - **403** account-state failures (`account_locked`,
1780
+ * `account_disabled`, `account_deleted`,
1781
+ * `account_deletion_expired`) → `GislAuthError`.
1782
+ * - **429** infrastructure rate-limit → `GislApiError` with
1783
+ * the `Retry-After` header echoed on the response.
1784
+ *
1785
+ * Node session persistence (cookie-jar across processes) is out of
1786
+ * scope — this method only touches the request side.
1787
+ */
1788
+ async login(credentials) {
1789
+ return this.request('POST', '/api/auth/login', {
1790
+ body: credentials,
1791
+ deserialize: LoginUser200ResponseDataFromJSON,
1792
+ });
1793
+ }
1794
+ /**
1795
+ * Invalidate the current session.
1796
+ *
1797
+ * Idempotent: calling logout without an active session returns 401,
1798
+ * but the SDK collapses both 200 and 401 into a single "logged out"
1799
+ * outcome — `logout()` resolves to `void` in either case so caller
1800
+ * cleanup code does not need to special-case the not-currently-
1801
+ * authenticated path. Other errors (e.g. 500, network failures)
1802
+ * still throw.
1803
+ */
1804
+ async logout() {
1805
+ try {
1806
+ await this.request('POST', '/api/auth/logout', {});
1807
+ }
1808
+ catch (err) {
1809
+ // Treat 401 as success (already logged out — idempotent per
1810
+ // contract). Logout 401 is a bare ErrorEnvelope with no
1811
+ // `error_type`, so it surfaces as the base GislApiError rather
1812
+ // than the typed GislAuthError — match on the status code to
1813
+ // capture both shapes.
1814
+ if (err instanceof GislApiError && err.statusCode === 401) {
1815
+ return;
1816
+ }
1817
+ throw err;
1818
+ }
1819
+ }
1820
+ // -----------------------------------------------------------------------
1821
+ // External imports
1822
+ // -----------------------------------------------------------------------
1823
+ /**
1824
+ * Register a one-shot bearer URL (S3 presigned, GCS signed, Azure
1825
+ * SAS, Dropbox shared link, public HTTPS) and receive an opaque
1826
+ * `externalSourceId` handle. Subsequent workflows reference the
1827
+ * handle via `WorkflowSource` of `type: external_import` —
1828
+ * compose with the [`externalImportSource()`](./types.ts) factory.
1829
+ *
1830
+ * Per ADR-0005 §"SSRF posture": the server validates 8 rules at
1831
+ * registration time AND again at fetch time. HTTPS-only;
1832
+ * private/loopback/cloud-metadata IPs are rejected (403). The
1833
+ * original URL + password are encrypted at rest and never
1834
+ * returned in any response.
1835
+ *
1836
+ * Currently `availability: planned` — the runtime endpoint returns
1837
+ * 422 `feature_not_available` (or 404, per the cross-repo rollout)
1838
+ * until the external-import infrastructure ships. The method
1839
+ * exists today so consumers can write the integration ahead of
1840
+ * time.
1841
+ */
1842
+ async createExternalImport(payload) {
1843
+ return this.request('POST', '/api/external-imports', {
1844
+ body: ExternalImportRequestToJSON(payload),
1845
+ deserialize: ExternalImportCreatedResponseFromJSON,
1846
+ });
1847
+ }
1848
+ // -----------------------------------------------------------------------
1849
+ // Audio watermark
1850
+ // -----------------------------------------------------------------------
1851
+ /**
1852
+ * Decode a previously-embedded steganographic audio watermark
1853
+ * (per ticket I20). Pairs with the `audio_watermark` operation —
1854
+ * the operation embeds; this endpoint decodes.
1855
+ *
1856
+ * **Enterprise tier only.** Free / pro callers receive
1857
+ * `GislFeatureTierRestrictedError` (403).
1858
+ *
1859
+ * **Own watermarks only.** The decoder will refuse to extract from
1860
+ * media the caller did not mark themselves — mismatches return 404
1861
+ * (rather than leaking that *some* watermark was detected).
1862
+ *
1863
+ * Currently `availability: planned` — calls return
1864
+ * `GislFeatureNotAvailableError` (422) until the cross-repo Lambda
1865
+ * support ships. Decode requests are rate-limited independently
1866
+ * from workflow-create.
1867
+ */
1868
+ async decodeAudioWatermark(payload) {
1869
+ // The generated request type is camelCase; convert to snake_case wire
1870
+ // shape before sending. Mirrors the multipart complete pattern.
1871
+ return this.request('POST', '/api/audio-watermark/decode', {
1872
+ body: AudioWatermarkDecodeRequestToJSON(payload),
1873
+ deserialize: AudioWatermarkDecodeResponseFromJSON,
1874
+ });
1875
+ }
1876
+ // -----------------------------------------------------------------------
1877
+ // Upload probe / preflight
1878
+ // -----------------------------------------------------------------------
1879
+ /**
1880
+ * Probe an uploaded file for workflow-readiness — detects corruption,
1881
+ * unsupported codecs, and pre-assigns the processing class the server
1882
+ * would route the file to. Designed for the long-form merge edge case
1883
+ * where a single bad input would fail the whole workflow.
1884
+ *
1885
+ * Currently `availability: planned` — calls return
1886
+ * `GislFeatureNotAvailableError` (422) until the cross-repo Lambda
1887
+ * support ships. Idempotent: probing the same `fileId` twice returns
1888
+ * the cached result.
1889
+ */
1890
+ async probeUpload(fileId) {
1891
+ return this.request('POST', `/api/uploads/${encodeURIComponent(fileId)}/probe`, {
1892
+ deserialize: UploadProbeResponseFromJSON,
1893
+ });
1894
+ }
1895
+ /**
1896
+ * Probe N uploaded files in parallel and partition the results by
1897
+ * outcome. Returns `{ ok, rejected, errors }` so the caller can
1898
+ * cleanly drop bad clips before submitting a long-form merge
1899
+ * workflow. Probe-call failures (including the
1900
+ * `feature_not_available` 422 returned while the endpoint is
1901
+ * `availability: planned`) land in `errors` rather than throwing,
1902
+ * so a partially-successful batch still yields useful aggregation.
1903
+ */
1904
+ async preflightClips(fileIds) {
1905
+ const settled = await Promise.allSettled(fileIds.map((fileId) => this.probeUpload(fileId)));
1906
+ const ok = [];
1907
+ const rejected = [];
1908
+ const errors = [];
1909
+ for (let i = 0; i < settled.length; i++) {
1910
+ const result = settled[i];
1911
+ const fileId = fileIds[i];
1912
+ if (result.status === 'fulfilled') {
1913
+ if (result.value.probeStatus === 'ok') {
1914
+ ok.push(result.value);
1915
+ }
1916
+ else {
1917
+ rejected.push(result.value);
1918
+ }
1919
+ }
1920
+ else {
1921
+ errors.push({ fileId, error: result.reason });
1922
+ }
1923
+ }
1924
+ return { ok, rejected, errors };
1925
+ }
1926
+ /**
1927
+ * Get a paginated page of credit transaction history for the caller.
1928
+ * Server defaults: `limit=20`, `offset=0`. Most-recent-first.
1929
+ */
1930
+ async getCreditsUsage(options = {}) {
1931
+ const params = new URLSearchParams();
1932
+ if (options.limit !== undefined)
1933
+ params.set('limit', String(options.limit));
1934
+ if (options.offset !== undefined)
1935
+ params.set('offset', String(options.offset));
1936
+ const query = params.toString();
1937
+ // String concatenation (not template) so the contract-drift path scanner
1938
+ // picks up the literal path. See getSchema for the same pattern.
1939
+ const path = query.length > 0
1940
+ ? '/api/v2/credits/usage' + '?' + query
1941
+ : '/api/v2/credits/usage';
1942
+ return this.request('GET', path, {
1943
+ deserialize: CreditsUsageResponseFromJSON,
1944
+ });
1945
+ }
331
1946
  }