@giveitsmaller/sdk 0.4.0 → 0.7.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.
Files changed (50) hide show
  1. package/dist/_audit.js +67 -0
  2. package/dist/builder.d.ts +406 -0
  3. package/dist/builder.js +706 -0
  4. package/dist/client.d.ts +96 -2
  5. package/dist/client.js +968 -33
  6. package/dist/credentials.d.ts +61 -0
  7. package/dist/credentials.js +200 -0
  8. package/dist/ergonomic/preset_resolver.d.ts +75 -0
  9. package/dist/ergonomic/preset_resolver.js +568 -0
  10. package/dist/ergonomic/presets/_translate.d.ts +11 -0
  11. package/dist/ergonomic/presets/_translate.js +35 -0
  12. package/dist/ergonomic/presets/audio_compress.d.ts +16 -0
  13. package/dist/ergonomic/presets/audio_compress.js +45 -0
  14. package/dist/ergonomic/presets/document_epub_compress.d.ts +14 -0
  15. package/dist/ergonomic/presets/document_epub_compress.js +34 -0
  16. package/dist/ergonomic/presets/document_odf_compress.d.ts +14 -0
  17. package/dist/ergonomic/presets/document_odf_compress.js +34 -0
  18. package/dist/ergonomic/presets/document_office_compress.d.ts +16 -0
  19. package/dist/ergonomic/presets/document_office_compress.js +40 -0
  20. package/dist/ergonomic/presets/document_pdf_compress.d.ts +14 -0
  21. package/dist/ergonomic/presets/document_pdf_compress.js +35 -0
  22. package/dist/ergonomic/presets/image_compress.d.ts +43 -0
  23. package/dist/ergonomic/presets/image_compress.js +95 -0
  24. package/dist/ergonomic/presets/index.d.ts +77 -0
  25. package/dist/ergonomic/presets/index.js +216 -0
  26. package/dist/ergonomic/presets/video_compress.d.ts +30 -0
  27. package/dist/ergonomic/presets/video_compress.js +83 -0
  28. package/dist/errors.d.ts +251 -1
  29. package/dist/errors.js +268 -0
  30. package/dist/generated/sdk_spec/enums.d.ts +195 -0
  31. package/dist/generated/sdk_spec/enums.js +127 -0
  32. package/dist/generated/sdk_spec/errors.d.ts +16 -0
  33. package/dist/generated/sdk_spec/errors.js +473 -0
  34. package/dist/generated/sdk_spec/index.d.ts +4 -0
  35. package/dist/generated/sdk_spec/index.js +7 -0
  36. package/dist/generated/sdk_spec/presets.d.ts +6 -0
  37. package/dist/generated/sdk_spec/presets.js +157 -0
  38. package/dist/generated/sdk_spec/version.d.ts +3 -0
  39. package/dist/generated/sdk_spec/version.js +6 -0
  40. package/dist/gisl.d.ts +112 -0
  41. package/dist/gisl.js +266 -0
  42. package/dist/index.d.ts +17 -7
  43. package/dist/index.js +33 -3
  44. package/dist/merge.d.ts +142 -0
  45. package/dist/merge.js +411 -0
  46. package/dist/sse.d.ts +20 -1
  47. package/dist/sse.js +62 -3
  48. package/dist/types.d.ts +144 -14
  49. package/dist/types.js +18 -0
  50. package/package.json +2 -2
package/dist/client.js CHANGED
@@ -1,7 +1,7 @@
1
- import { readFileSync, statSync } from 'node:fs';
1
+ import { open, stat } from 'node:fs/promises';
2
2
  import { basename } from 'node:path';
3
- import { AudioWatermarkDecodeRequestToJSON, AudioWatermarkDecodeResponseFromJSON, ExternalImportCreatedResponseFromJSON, ExternalImportRequestToJSON, LoginUser200ResponseDataFromJSON, CreditsBalanceResponseFromJSON, CreditsUsageResponseFromJSON, UploadResponseFromJSON, UploadProbeResponseFromJSON, MultipartInitiateResponseFromJSON, MultipartInitiateRequestMetadataHintToJSON, MultipartCompleteResponseFromJSON, MultipartCompleteRequestToJSON, WorkflowCancelResponseFromJSON, WorkflowCreateResponseFromJSON, WorkflowResumeResponseFromJSON, WorkflowStatusResponseFromJSON, WorkflowDownloadResponseFromJSON, MetadataResponseFromJSON, OperationsSchemaResponseFromJSON, RetryResponseFromJSON, WorkflowStatus, AuthErrorResponseFromJSON, AuthErrorType, BalanceExhaustedResponseFromJSON, BalanceExhaustedResponseRequiredActionEnum, FeatureNotAvailableResponseFromJSON, FeatureTierRestrictedResponseFromJSON, TierRestrictionKind, TierRestrictionResponseFromJSON, UserTier, WorkflowExpiredResponseFromJSON, UploadThresholdsSingleShotMaxBytesEnum, UploadThresholdsMultipartChunkSizeEnum, UploadThresholdsMultipartConcurrencyDefaultEnum, } from '@giveitsmaller/contracts/openapi';
4
- import { GislAbortError, GislApiError, GislAuthError, GislBalanceExhaustedError, GislError, GislFeatureNotAvailableError, GislFeatureTierRestrictedError, GislTierRestrictedError, GislTimeoutError, GislValidationError, GislWorkflowExpiredError, } from './errors.js';
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, ProbePendingResponseFromJSON, 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, GislProbePendingError, GislUploadCapExceededError, GislValidationError, GislWorkflowExpiredError, } from './errors.js';
5
5
  import { parseSseStream } from './sse.js';
6
6
  const DEFAULT_TIMEOUT_MS = 30_000;
7
7
  // SDK-internal aliases derived from the contract-pinned UploadThresholds enums
@@ -11,7 +11,7 @@ const DEFAULT_TIMEOUT_MS = 30_000;
11
11
  // release that regenerates the corresponding *Enum, plus updating the literal
12
12
  // in the matching `_AssertTrue<>` line.
13
13
  const SINGLE_SHOT_MAX_BYTES = UploadThresholdsSingleShotMaxBytesEnum.NUMBER_10000000;
14
- const MULTIPART_CHUNK_SIZE = UploadThresholdsMultipartChunkSizeEnum.NUMBER_5242880;
14
+ const MULTIPART_CHUNK_SIZE = UploadThresholdsMultipartChunkSizeEnum.NUMBER_16777216;
15
15
  export const MULTIPART_CONCURRENCY_DEFAULT = UploadThresholdsMultipartConcurrencyDefaultEnum.NUMBER_4;
16
16
  const DEFAULT_MULTIPART_MAX_ATTEMPTS = 3;
17
17
  const DEFAULT_MULTIPART_RETRY_BASE_MS = 500;
@@ -23,6 +23,33 @@ const DEFAULT_MULTIPART_RETRY_BASE_MS = 500;
23
23
  // TODO(58nBQLWQ): replace with UploadThresholdsMultipartFirstChunkSizeEnum
24
24
  // once contracts ticket promotes this to a typed const (v2.3.1 follow-up).
25
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
26
53
  const DEFAULT_POLL_INTERVAL_MS = 2_000;
27
54
  const DEFAULT_POLL_TIMEOUT_MS = 300_000; // 5 min
28
55
  // Statuses that waitForWorkflow() returns immediately on. Per ticket I24,
@@ -176,6 +203,69 @@ function bindAbortSignal(external, internal, onExternalAbort) {
176
203
  external.addEventListener('abort', onAbort, { once: true });
177
204
  return () => external.removeEventListener('abort', onAbort);
178
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
+ }
179
269
  export class GislClient {
180
270
  baseUrl;
181
271
  headers;
@@ -373,6 +463,79 @@ export class GislClient {
373
463
  if (status === 422 && errorType === 'workflow_expired') {
374
464
  tryThrowStructured(WorkflowExpiredResponseFromJSON, GislWorkflowExpiredError, (p) => isValidDate(p.expiredAt));
375
465
  }
466
+ // Probe-pending 422 on POST /api/workflows (per contracts av1J0rEF).
467
+ // Recovery: caller polls /api/uploads/{id}/probe until terminal, then
468
+ // retries the workflow-create. `payload.jobRef` names which job.
469
+ //
470
+ // Defensive against v2.15.3 generator-bug: the openapi-generator-emitted
471
+ // `instanceOfProbePendingResponse` checks camelCase fields against the
472
+ // raw snake_case wire — fails dispatch when used at the CreateWorkflow
473
+ // 422 union top-level. We branch on the already-parsed `error_type`
474
+ // here (the SDK-side snake_case envelope read at line 659) and
475
+ // validate the payload shape directly on the raw JSON before
476
+ // ProbePendingResponseFromJSON converts snake_case → camelCase.
477
+ // Robust to BOTH the current v2.15.3 broken dispatch AND any future
478
+ // v2.15.4 fix that lands a discriminator.
479
+ if (status === 422 && errorType === 'probe_pending') {
480
+ tryThrowStructured(ProbePendingResponseFromJSON, GislProbePendingError, (p) => typeof p.jobRef === 'string' && p.jobRef.length > 0);
481
+ }
482
+ // Upload cap errors. `GislUploadCapExceededError` takes an extra `kind`
483
+ // arg so it cannot use `tryThrowStructured` (whose ErrorClass signature
484
+ // is fixed) — this local helper applies the SAME defense-in-depth
485
+ // discipline: construct via FromJSON, validate required typed fields,
486
+ // fall through to the generic `GislApiError` on any malformed envelope.
487
+ const tryThrowCap = (construct, kind, validate) => {
488
+ let payload;
489
+ try {
490
+ payload = construct(json);
491
+ }
492
+ catch {
493
+ return undefined;
494
+ }
495
+ if (!validate(payload)) {
496
+ return undefined;
497
+ }
498
+ throw new GislUploadCapExceededError(status, errorMessage, kind, payload, path, i18n);
499
+ };
500
+ if (status === 422 && errorType === 'upload_size_exceeds_tier') {
501
+ tryThrowCap(UploadSizeExceedsTierResponseFromJSON, 'size_tier', (p) => isInEnum(p.currentTier, UserTier) &&
502
+ typeof p.maxSizeBytes === 'number');
503
+ }
504
+ if (status === 422 && errorType === 'upload_duration_exceeds_tier') {
505
+ tryThrowCap(UploadDurationExceedsTierResponseFromJSON, 'duration_tier', (p) => isInEnum(p.currentTier, UserTier) &&
506
+ typeof p.maxDurationSeconds === 'number');
507
+ }
508
+ // 413 = the absolute across-tier cap. The contract models 413 as a
509
+ // plain `ErrorEnvelope` (no `error_type` discriminator, no typed
510
+ // payload — api.yaml), so dispatch purely on status with no FromJSON
511
+ // and an undefined payload (the `absolute_413` kind tells the caller
512
+ // there is intentionally no structured envelope to read).
513
+ if (status === 413) {
514
+ throw new GislUploadCapExceededError(status, errorMessage, 'absolute_413', undefined, path, i18n);
515
+ }
516
+ // SDK-3 (Wb6ebOMM) resume-support endpoint error codes. API-2 / PR
517
+ // #283 specced these as plain `ErrorEnvelope` envelopes with the
518
+ // discriminating string on `error_type`. No typed payload to build —
519
+ // dispatch on the (status, error_type) tuple. The HxUmVr3Y contract
520
+ // regen will produce typed responses for these; today the 3 typed
521
+ // subclasses carry only the localisation triple + raw envelope.
522
+ if (status === 404 && errorType === 'MULTIPART_SESSION_NOT_FOUND') {
523
+ throw new GislMultipartSessionNotFoundError(status, errorMessage, path, i18n);
524
+ }
525
+ if (status === 403 && errorType === 'MULTIPART_SESSION_OWNERSHIP') {
526
+ throw new GislMultipartSessionOwnershipError(status, errorMessage, path, i18n);
527
+ }
528
+ if (status === 403 && errorType === 'MULTIPART_SESSION_AUTH_REQUIRED') {
529
+ throw new GislMultipartSessionAuthRequiredError(status, errorMessage, path, i18n);
530
+ }
531
+ // 422 `FILE_TOO_LARGE_FOR_MULTIPART` — pre-S3 capacity reject on the
532
+ // resume-support presign endpoint (more parts than the manifest can
533
+ // ever accept). No typed payload today (the contract carries no
534
+ // structured response for this code); `cap_v2_multipart` discriminant
535
+ // is documented on `GislUploadCapKind`.
536
+ if (status === 422 && errorType === 'FILE_TOO_LARGE_FOR_MULTIPART') {
537
+ throw new GislUploadCapExceededError(status, errorMessage, 'cap_v2_multipart', undefined, path, i18n);
538
+ }
376
539
  throw new GislApiError(status, errorMessage, path, json.details, { ...i18n, payload: json });
377
540
  }
378
541
  const data = json.data ?? json;
@@ -395,34 +558,55 @@ export class GislClient {
395
558
  * @param options Upload options including progress callback.
396
559
  */
397
560
  async uploadFile(file, options) {
398
- // Pre-abort check: bail before statSync/readFileSync buffers the whole
399
- // file into memory when the caller has already cancelled.
561
+ // Pre-abort check: bail before touching the filesystem when the caller
562
+ // has already cancelled.
400
563
  if (options?.signal?.aborted) {
401
564
  throw new GislAbortError('Upload aborted before start');
402
565
  }
403
- let blob;
566
+ let source;
404
567
  let fileName;
405
- let fileSize;
406
568
  if (typeof file === 'string') {
407
- const stat = statSync(file);
408
- fileSize = stat.size;
569
+ // `stat` for the size only — the bytes are NEVER read up front. The old
570
+ // path did `readFileSync(file)` which OOMs on multi-GB files and is
571
+ // impossible above the libuv INT32_MAX single-read ceiling regardless
572
+ // of available memory (see fileByteSource / LIBUV_MAX_SINGLE_READ_BYTES).
573
+ const stats = await stat(file);
409
574
  fileName = basename(file);
410
- const content = readFileSync(file);
411
- blob = new Blob([content]);
575
+ source = fileByteSource(file, stats.size);
412
576
  }
413
577
  else {
414
- blob = file;
415
578
  fileName = file.name ?? 'upload';
416
- fileSize = file.size;
579
+ source = blobByteSource(file);
417
580
  }
418
- if (fileSize > this.multipartThreshold) {
419
- return this.multipartUpload(blob, fileName, fileSize, options);
581
+ if (typeof options?.resumeUploadId === 'string' && options.resumeUploadId !== '') {
582
+ // SDK-3 (Wb6ebOMM): resume path takes the durable session's
583
+ // `recommended_chunk_size` from the /status envelope rather than
584
+ // the initiate envelope (initiate is skipped). Below the multipart
585
+ // threshold a resume is still meaningful — the original session was
586
+ // started as multipart, so a sub-threshold file CAN'T be a "resume
587
+ // target" in practice. Guard explicitly so a confused caller gets a
588
+ // clear error rather than a 404 on /status.
589
+ if (source.size <= this.multipartThreshold) {
590
+ throw new GislError('uploadFile: resumeUploadId set but file size is at-or-below the multipart ' +
591
+ `threshold (${this.multipartThreshold} bytes); resume targets must be multipart sessions.`);
592
+ }
593
+ return this.multipartResume(source, fileName, source.size, options.resumeUploadId, options);
420
594
  }
421
- return this.singleUpload(blob, fileName, options);
595
+ if (source.size > this.multipartThreshold) {
596
+ return this.multipartUpload(source, fileName, source.size, options);
597
+ }
598
+ return this.singleUpload(source, fileName, options);
422
599
  }
423
- async singleUpload(blob, fileName, options) {
600
+ async singleUpload(source, fileName, options) {
424
601
  const form = new FormData();
425
- form.append('file', blob, fileName);
602
+ // Single-shot is gated to <= single_shot_max_bytes (10 MB) by the router
603
+ // above, so this one bounded read is trivially under the libuv ceiling
604
+ // and a non-issue for memory. `slice` returns a Blob (the file-path
605
+ // source wraps the bounded Buffer) so FormData.append is unchanged —
606
+ // multipart never wraps a whole-file Blob, only this <=10 MB single-shot
607
+ // path ever holds a full payload Blob.
608
+ const body = await source.slice(0, source.size);
609
+ form.append('file', body, fileName);
426
610
  return this.request('POST', '/api/uploads', {
427
611
  body: form,
428
612
  json: false,
@@ -441,10 +625,10 @@ export class GislClient {
441
625
  * comes from the initiate response's first-chunk detection; for authoritative
442
626
  * post-upload metadata callers should use getMetadata(fileId).
443
627
  */
444
- async multipartUpload(blob, fileName, totalSize, options) {
628
+ async multipartUpload(source, fileName, totalSize, options) {
445
629
  // Step 1: Initiate with first chunk
446
630
  const firstChunkSize = Math.min(totalSize, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
447
- const firstChunk = blob.slice(0, firstChunkSize);
631
+ const firstChunk = await source.slice(0, firstChunkSize);
448
632
  const initiateForm = new FormData();
449
633
  initiateForm.append('file', firstChunk, fileName);
450
634
  initiateForm.append('filename', fileName);
@@ -473,6 +657,88 @@ export class GislClient {
473
657
  const etags = [];
474
658
  const presignedUrls = initResponse.presignedUrls;
475
659
  const chunkSize = initResponse.recommendedChunkSize;
660
+ // MultipartInitiateResponseFromJSON does NO runtime validation (unlike
661
+ // the strict PHP generated model, which rejects these at deserialize —
662
+ // the documented lax-TS-vs-strict-PHP divergence). The TS SDK must
663
+ // therefore enforce, before any chunk read/PUT, what PHP gets for free
664
+ // from its generated model + its explicit pre-loop guards (codex review):
665
+ //
666
+ // (a) uploadId must be a non-empty string. `FromJSON` assigns
667
+ // `json['upload_id']` directly, so a malformed initiate could
668
+ // otherwise produce a typed GislMultipartPartError whose `uploadId`
669
+ // is `undefined` and synthesise a bogus UploadResponse.fileId —
670
+ // mirrors the PHP pre-loop `is_string && !== ''` guard.
671
+ if (typeof initResponse.uploadId !== 'string' ||
672
+ initResponse.uploadId === '') {
673
+ throw new GislError('Multipart initiate response missing or empty upload_id.');
674
+ }
675
+ // (b) recommendedChunkSize must be a finite number INSIDE the contract
676
+ // range [MULTIPART_CHUNK_SIZE, RECOMMENDED_CHUNK_SIZE_MAX_BYTES]. The
677
+ // old `>= 1` check let a malformed/hostile huge value pass the
678
+ // part-count guard and drive `fileByteSource` into an unbounded
679
+ // `Buffer.allocUnsafe(length)` — the memory-blowup class this SDK
680
+ // exists to prevent. PHP's strict generated model already rejects
681
+ // out-of-range values at deserialize; this is the TS equivalent.
682
+ if (typeof chunkSize !== 'number' ||
683
+ !Number.isInteger(chunkSize) ||
684
+ chunkSize < MULTIPART_CHUNK_SIZE ||
685
+ chunkSize > RECOMMENDED_CHUNK_SIZE_MAX_BYTES) {
686
+ // `Number.isInteger` also rejects NaN/Infinity and a fractional
687
+ // `recommended_chunk_size` (e.g. 5242880.5) that would otherwise
688
+ // reach `Buffer.allocUnsafe(fractional)` and fail later as a
689
+ // misleading part-read error (codex review).
690
+ throw new GislError('Multipart initiate response recommendedChunkSize is missing or ' +
691
+ `outside the contract range [${MULTIPART_CHUNK_SIZE}, ` +
692
+ `${RECOMMENDED_CHUNK_SIZE_MAX_BYTES}]: got ${String(chunkSize)}.`);
693
+ }
694
+ // S3 <=10 000-part ceiling guard (Model A). The server computes and
695
+ // returns `totalParts`; we trust it (consistent with how the SDK already
696
+ // trusts `recommendedChunkSize`/`presignedUrls` from the same envelope)
697
+ // but assert the ceiling, cross-checked against a client-side recompute
698
+ // from the same `chunkSize`. This necessarily fires AFTER the initiate
699
+ // round-trip + 8 MiB first-chunk upload — `totalParts` and `chunkSize`
700
+ // only exist on the initiate response, so a pure pre-flight check is
701
+ // impossible under Model A (this is the card-mandated trade-off).
702
+ const remainingBytes = Math.max(0, totalSize - firstChunkSize);
703
+ const computedParts = 1 + Math.ceil(remainingBytes / chunkSize);
704
+ const serverParts = initResponse.totalParts;
705
+ // `FromJSON` passes `total_parts` through unvalidated. Reject a
706
+ // missing/non-integer value here so the ≤10k guard's
707
+ // `Math.max(serverParts, computedParts)` cannot surface `NaN` in the
708
+ // GislMultipartPartCountError (codex review). Mirrors the uploadId /
709
+ // chunkSize guards above (the lax-TS-vs-strict-PHP-model divergence).
710
+ if (typeof serverParts !== 'number' ||
711
+ !Number.isInteger(serverParts) ||
712
+ serverParts < 1) {
713
+ throw new GislError('Multipart initiate response missing or invalid total_parts: ' +
714
+ `got ${String(serverParts)}.`);
715
+ }
716
+ if (serverParts > S3_MAX_MULTIPART_PARTS ||
717
+ computedParts > S3_MAX_MULTIPART_PARTS) {
718
+ throw new GislMultipartPartCountError(`Upload requires ${Math.max(serverParts, computedParts)} parts, ` +
719
+ `exceeding the S3 ${S3_MAX_MULTIPART_PARTS}-part multipart limit ` +
720
+ `(server reported ${serverParts}, client computed ${computedParts} ` +
721
+ `at ${chunkSize}-byte chunks). A larger chunk size is required ` +
722
+ 'server-side to upload a file this large.', Math.max(serverParts, computedParts), S3_MAX_MULTIPART_PARTS);
723
+ }
724
+ // Plan-consistency guard (codex review). The ≤10k ceiling above only
725
+ // bounds the count; it does NOT catch an initiate plan that is internally
726
+ // inconsistent BELOW the cap. Under Model A a contract-compliant server
727
+ // computes `total_parts` from the same `recommended_chunk_size` it
728
+ // returns, and emits exactly one presigned URL per remaining part (part 1
729
+ // is the initiate first chunk). If `total_parts`, the client recompute,
730
+ // and `presigned_urls.length` disagree, proceeding would PUT the wrong
731
+ // number of byte ranges (or wrong offsets) and only fail opaquely at
732
+ // /multipart/complete. Fail fast here with the discrepancy instead.
733
+ if (!Number.isFinite(serverParts) ||
734
+ serverParts !== computedParts ||
735
+ presignedUrls.length !== computedParts - 1) {
736
+ throw new GislError('Multipart initiate plan is internally inconsistent: server ' +
737
+ `total_parts=${serverParts}, client computed ${computedParts} ` +
738
+ `from ${chunkSize}-byte chunks, presigned_urls.length=` +
739
+ `${presignedUrls.length} (expected ${computedParts - 1}). ` +
740
+ 'Refusing to upload a mismatched part plan.');
741
+ }
476
742
  // Internal abort signal that workers use to short-circuit each others'
477
743
  // backoff sleeps. When any worker hits a terminal failure it aborts this
478
744
  // controller, which races the caller's signal inside sleepWithSignal so
@@ -536,11 +802,33 @@ export class GislClient {
536
802
  const part = presignedUrls[index];
537
803
  const start = firstChunkSize + index * chunkSize;
538
804
  const end = Math.min(start + chunkSize, totalSize);
539
- // Blob.slice() returns a new Blob view; the underlying bytes are
540
- // immutable so the same `chunk` may be re-sent across retry attempts.
541
- // S3 multipart parts are idempotent by partNumber a re-PUT overwrites,
542
- // there is no duplicate-data risk.
543
- const chunk = blob.slice(start, end);
805
+ // Read this part's bytes ONCE here, then reuse the captured chunk
806
+ // across every retry attempt below so a retry never re-reads the
807
+ // file and the re-PUT is byte-identical (S3 parts are idempotent by
808
+ // partNumber; a re-PUT overwrites, no duplicate-data risk).
809
+ //
810
+ // Live-file caveat (streaming divergence from the old
811
+ // readFileSync→Blob path): the old code snapshotted the whole file at
812
+ // t0 so every part was point-in-time consistent. Streaming reads each
813
+ // part at the instant it is first uploaded, so a file mutated
814
+ // mid-upload yields parts from different instants. Truncation is
815
+ // caught by fileByteSource's short-read guard; full point-in-time
816
+ // snapshotting is resumable/staged-upload territory (SDK-3, Wb6ebOMM).
817
+ // Surface a read failure for THIS part as the typed
818
+ // GislMultipartPartError (with partNumber + uploadId), consistent with
819
+ // the PUT-failure path below — a bare GislError from fileByteSource
820
+ // (short read / libuv ceiling) would otherwise lose the per-part
821
+ // context (codex review). An abort must stay GislAbortError.
822
+ let chunk;
823
+ try {
824
+ chunk = await source.slice(start, end);
825
+ }
826
+ catch (err) {
827
+ if (err instanceof GislAbortError)
828
+ throw err;
829
+ throw new GislMultipartPartError(`Failed to read bytes for part ${part.partNumber}: ` +
830
+ (err instanceof Error ? err.message : String(err)), part.partNumber, initResponse.uploadId);
831
+ }
544
832
  const contentLength = end - start;
545
833
  let lastErr = null;
546
834
  for (let attempt = 0; attempt < this.multipartMaxAttempts; attempt++) {
@@ -576,8 +864,8 @@ export class GislClient {
576
864
  // forcing them to wait out their backoff timer.
577
865
  await sleepWithEitherSignal(delay, options?.signal, failureController.signal);
578
866
  }
579
- throw new GislError(`S3 chunk upload failed for part ${part.partNumber} after ${this.multipartMaxAttempts} attempts: ` +
580
- (lastErr instanceof Error ? lastErr.message : String(lastErr)));
867
+ throw new GislMultipartPartError(`S3 chunk upload failed for part ${part.partNumber} after ${this.multipartMaxAttempts} attempts: ` +
868
+ (lastErr instanceof Error ? lastErr.message : String(lastErr)), part.partNumber, initResponse.uploadId);
581
869
  };
582
870
  // Upload with concurrency limit. Workers check the signal before pulling
583
871
  // the next queue item so a mid-upload abort drains fast without
@@ -639,7 +927,9 @@ export class GislClient {
639
927
  fileId: completeResp.uploadId,
640
928
  originalName: fileName,
641
929
  mimeType: initResponse.mimeType,
642
- sizeBytes: blob.size,
930
+ // `totalSize` (from fs.stat / Blob.size) — the streaming path no longer
931
+ // holds a whole-file Blob to read `.size` off.
932
+ sizeBytes: totalSize,
643
933
  // Preserved from the initiate response: v2 contract makes
644
934
  // `constraintsApplied` a REQUIRED field on UploadResponse, and the
645
935
  // multipart/complete endpoint does not re-emit it. The first-chunk probe
@@ -647,6 +937,549 @@ export class GislClient {
647
937
  constraintsApplied: initResponse.constraintsApplied,
648
938
  };
649
939
  }
940
+ /**
941
+ * SDK-3 (Wb6ebOMM): resume an in-progress multipart upload.
942
+ *
943
+ * Skips `/multipart/initiate` entirely (the original initiate happened in a
944
+ * prior process). Walks `/status` for the authoritative list of recorded
945
+ * parts, re-presigns the missing ones in batches of <=100, PUTs only those,
946
+ * and finalises with `/complete`. Caller's `source` MUST be byte-identical
947
+ * to the originally-uploaded file at the same offsets (parts whose etags
948
+ * don't match server state will fail `/complete`).
949
+ *
950
+ * Re-runs the same `uploadId` / `chunkSize` / `totalParts` / plan-consistency
951
+ * guards as the fresh-upload path (`multipartUpload`), using the /status
952
+ * envelope as the equivalent of the initiate envelope. Reuses the same
953
+ * `failureController` sibling-wake + `drainResponseBody` cleanup discipline
954
+ * as the fresh-upload PUT loop. `onProgress` fires on entry seeded from
955
+ * (uploadedPartNumbers.length * chunkSize) and again after every successful
956
+ * PUT. `onCheckpoint` fires OUTSIDE the retry-scoped path after every
957
+ * successful PUT — a callback-throw must not trigger a duplicate PUT.
958
+ *
959
+ * TODO(HxUmVr3Y): replace inline hand-coded request body marshalling on regen.
960
+ */
961
+ async multipartResume(source, fileName, totalSize, resumeUploadId, options) {
962
+ // Step 1: Walk /status for the authoritative session state.
963
+ const status = await this.walkUploadStatus(resumeUploadId, {
964
+ signal: options?.signal,
965
+ });
966
+ // Validate the /status envelope shape, mirroring the fresh-upload
967
+ // post-initiate guards (`multipartUpload` lines around the
968
+ // total_parts / recommendedChunkSize / uploadId validation block).
969
+ if (typeof status.uploadId !== 'string' ||
970
+ status.uploadId === '' ||
971
+ status.uploadId !== resumeUploadId) {
972
+ throw new GislError('multipartResume: /status response uploadId does not match resumeUploadId.');
973
+ }
974
+ const chunkSize = status.recommendedChunkSize;
975
+ if (typeof chunkSize !== 'number' ||
976
+ !Number.isInteger(chunkSize) ||
977
+ chunkSize < MULTIPART_CHUNK_SIZE ||
978
+ chunkSize > RECOMMENDED_CHUNK_SIZE_MAX_BYTES) {
979
+ throw new GislError('multipartResume: /status recommendedChunkSize missing or outside the contract ' +
980
+ `range [${MULTIPART_CHUNK_SIZE}, ${RECOMMENDED_CHUNK_SIZE_MAX_BYTES}]: got ${String(chunkSize)}.`);
981
+ }
982
+ if (typeof status.totalParts !== 'number' ||
983
+ !Number.isInteger(status.totalParts) ||
984
+ status.totalParts < 1) {
985
+ throw new GislError(`multipartResume: /status totalParts missing or invalid: got ${String(status.totalParts)}.`);
986
+ }
987
+ if (status.totalParts > S3_MAX_MULTIPART_PARTS) {
988
+ throw new GislMultipartPartCountError(`multipartResume: /status totalParts=${status.totalParts} exceeds the S3 ` +
989
+ `${S3_MAX_MULTIPART_PARTS}-part multipart limit.`, status.totalParts, S3_MAX_MULTIPART_PARTS);
990
+ }
991
+ // Sanity-check the caller's byte source against the server's recorded
992
+ // plan. Mirrors the fresh-upload chunk-plan: part 1 = firstChunkSize
993
+ // (8 MiB), parts 2..totalParts each consume chunkSize bytes (last part
994
+ // may be a short tail). Reject a wrong-file resume here — /complete
995
+ // would otherwise fail on etag mismatch.
996
+ const firstChunkSize = Math.min(totalSize, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
997
+ const expectedMinBytes = firstChunkSize + Math.max(0, status.totalParts - 2) * chunkSize + (status.totalParts > 1 ? 1 : 0);
998
+ const expectedMaxBytes = firstChunkSize + Math.max(0, status.totalParts - 1) * chunkSize;
999
+ if (totalSize < expectedMinBytes || totalSize > expectedMaxBytes) {
1000
+ throw new GislError(`multipartResume: caller file size (${totalSize}) does not match the resumed ` +
1001
+ `session's recorded plan (totalParts=${status.totalParts}, chunkSize=${chunkSize}, ` +
1002
+ `expected ${expectedMinBytes}-${expectedMaxBytes} bytes). Wrong file for this uploadId?`);
1003
+ }
1004
+ // Step 2: Compute missing parts. Server records `uploadedParts` as the
1005
+ // authoritative set; everything in [1, totalParts] not in that set is
1006
+ // still-to-upload. Part 1 was uploaded inline at initiate — if it is
1007
+ // missing from /status the session is unrecoverable (the server rejects
1008
+ // re-presigning part 1 to preserve the recorded etag for /complete).
1009
+ const uploaded = new Map();
1010
+ for (const p of status.uploadedParts) {
1011
+ uploaded.set(p.partNumber, p);
1012
+ }
1013
+ if (!uploaded.has(1)) {
1014
+ throw new GislError('multipartResume: part 1 (initiate first chunk) is missing from /status. ' +
1015
+ 'Part 1 is sealed at initiate and cannot be re-presigned; this session is unrecoverable. ' +
1016
+ 'Start a fresh upload (call uploadFile without resumeUploadId).');
1017
+ }
1018
+ const missingParts = [];
1019
+ for (let n = 2; n <= status.totalParts; n++) {
1020
+ if (!uploaded.has(n))
1021
+ missingParts.push(n);
1022
+ }
1023
+ // Seed uploadedBytes from already-uploaded parts so onProgress reflects
1024
+ // the true resumption point. Server reports authoritative part sizes
1025
+ // via `sizeBytes`; sum those rather than guessing chunkSize * count
1026
+ // (the last part may be a short tail).
1027
+ let uploadedBytes = 0;
1028
+ for (const p of status.uploadedParts) {
1029
+ uploadedBytes += p.sizeBytes;
1030
+ }
1031
+ options?.onProgress?.(uploadedBytes, totalSize);
1032
+ const fireCheckpoint = (extraPartNumber) => {
1033
+ const all = [...uploaded.keys()];
1034
+ if (extraPartNumber !== undefined)
1035
+ all.push(extraPartNumber);
1036
+ all.sort((a, b) => a - b);
1037
+ const state = {
1038
+ uploadId: status.uploadId,
1039
+ totalParts: status.totalParts,
1040
+ uploadedPartNumbers: all,
1041
+ manifestExpiresAt: status.manifestExpiresAt,
1042
+ };
1043
+ // Callback fires OUTSIDE retry-scope. A throw here propagates and
1044
+ // fails the upload but cannot trigger a duplicate PUT.
1045
+ options?.onCheckpoint?.(state);
1046
+ };
1047
+ // Fire an entry checkpoint so callers can persist the resumed state
1048
+ // even before any new PUT lands. Useful when the missing-parts list is
1049
+ // empty (everything already uploaded except /complete) — see below.
1050
+ fireCheckpoint();
1051
+ // Short-circuit: every part is already uploaded. Skip presign + PUT
1052
+ // and go straight to /complete with the etags the server has on file.
1053
+ const newEtags = [];
1054
+ if (missingParts.length === 0) {
1055
+ // No PUTs to run; proceed to /complete below with just the recorded parts.
1056
+ }
1057
+ else {
1058
+ // Step 3: For each batch of <=100 missing parts, re-presign + PUT.
1059
+ // We process batches sequentially (presign call) but PUTs within each
1060
+ // batch run concurrently up to multipartConcurrency, mirroring the
1061
+ // fresh-upload worker-pool semantics.
1062
+ const failureController = new AbortController();
1063
+ const putOne = async (part) => {
1064
+ // Offset math mirrors the fresh-upload path
1065
+ // (`multipartUpload`'s `uploadChunk`): part 1 is the initiate's 8 MiB
1066
+ // first chunk, parts 2..N each consume chunkSize bytes starting at
1067
+ // firstChunkSize. The resume path never PUTs part 1 (rejected
1068
+ // earlier as unrecoverable), so partNumber here is always >= 2.
1069
+ const firstChunkSize = Math.min(totalSize, DEFAULT_MULTIPART_FIRST_CHUNK_SIZE);
1070
+ const start = firstChunkSize + (part.partNumber - 2) * chunkSize;
1071
+ const end = Math.min(start + chunkSize, totalSize);
1072
+ const contentLength = end - start;
1073
+ let chunk;
1074
+ try {
1075
+ chunk = await source.slice(start, end);
1076
+ }
1077
+ catch (err) {
1078
+ if (err instanceof GislAbortError)
1079
+ throw err;
1080
+ throw new GislMultipartPartError(`multipartResume: failed to read bytes for part ${part.partNumber}: ` +
1081
+ (err instanceof Error ? err.message : String(err)), part.partNumber, status.uploadId);
1082
+ }
1083
+ let lastErr = null;
1084
+ for (let attempt = 0; attempt < this.multipartMaxAttempts; attempt++) {
1085
+ if (options?.signal?.aborted) {
1086
+ throw new GislAbortError(`multipartResume: S3 part ${part.partNumber} upload aborted`);
1087
+ }
1088
+ if (failureController.signal.aborted) {
1089
+ throw new GislError(`multipartResume: S3 part ${part.partNumber} upload abandoned after sibling failure`);
1090
+ }
1091
+ let s3Response;
1092
+ try {
1093
+ s3Response = await fetch(part.url, {
1094
+ method: 'PUT',
1095
+ body: chunk,
1096
+ headers: { 'Content-Length': contentLength.toString() },
1097
+ signal: options?.signal,
1098
+ });
1099
+ }
1100
+ catch (err) {
1101
+ if (isAbortError(err) && options?.signal?.aborted) {
1102
+ throw new GislAbortError(`multipartResume: S3 part ${part.partNumber} upload aborted`);
1103
+ }
1104
+ // Non-user-abort AbortError (e.g. transport cleanup) must still
1105
+ // surface as a typed GislError subclass — never as a raw
1106
+ // DOMException — to preserve the "every multipart failure is a
1107
+ // typed GislError" contract (code-reviewer P7).
1108
+ if (isAbortError(err)) {
1109
+ throw new GislMultipartPartError(`multipartResume: S3 part ${part.partNumber} aborted by transport: ` +
1110
+ (err instanceof Error ? err.message : String(err)), part.partNumber, status.uploadId);
1111
+ }
1112
+ if (isRetryableNetworkError(err)) {
1113
+ lastErr = err;
1114
+ if (attempt + 1 >= this.multipartMaxAttempts)
1115
+ break;
1116
+ const delay = fullJitterDelay(this.multipartRetryBaseMs, attempt);
1117
+ await sleepWithEitherSignal(delay, options?.signal, failureController.signal);
1118
+ continue;
1119
+ }
1120
+ throw err;
1121
+ }
1122
+ if (s3Response.ok) {
1123
+ const etag = s3Response.headers.get('etag');
1124
+ if (!etag) {
1125
+ await drainResponseBody(s3Response);
1126
+ throw new GislError(`multipartResume: S3 response missing ETag for part ${part.partNumber}`);
1127
+ }
1128
+ // Successful PUT — record etag, apply progress + checkpoint side
1129
+ // effects OUTSIDE the retry-scoped path (mirrors the fresh-upload
1130
+ // discipline at multipartUpload's ok-branch).
1131
+ newEtags.push({ partNumber: part.partNumber, etag });
1132
+ uploaded.set(part.partNumber, {
1133
+ partNumber: part.partNumber,
1134
+ etag,
1135
+ sizeBytes: contentLength,
1136
+ lastModified: new Date().toISOString(),
1137
+ });
1138
+ uploadedBytes = Math.min(uploadedBytes + contentLength, totalSize);
1139
+ options?.onProgress?.(uploadedBytes, totalSize);
1140
+ fireCheckpoint();
1141
+ return;
1142
+ }
1143
+ await drainResponseBody(s3Response);
1144
+ if (!isRetryableStatus(s3Response.status)) {
1145
+ throw new GislError(`multipartResume: S3 chunk upload failed for part ${part.partNumber}: HTTP ${s3Response.status} (non-retryable)`);
1146
+ }
1147
+ lastErr = new GislError(`multipartResume: S3 chunk upload failed for part ${part.partNumber}: HTTP ${s3Response.status}`);
1148
+ if (attempt + 1 >= this.multipartMaxAttempts)
1149
+ break;
1150
+ const delay = fullJitterDelay(this.multipartRetryBaseMs, attempt);
1151
+ await sleepWithEitherSignal(delay, options?.signal, failureController.signal);
1152
+ }
1153
+ throw new GislMultipartPartError(`multipartResume: S3 chunk upload failed for part ${part.partNumber} after ${this.multipartMaxAttempts} attempts: ` +
1154
+ (lastErr instanceof Error ? lastErr.message : String(lastErr)), part.partNumber, status.uploadId);
1155
+ };
1156
+ // Drive batches of <=100 part numbers.
1157
+ const PRESIGN_BATCH_SIZE = 100;
1158
+ for (let i = 0; i < missingParts.length; i += PRESIGN_BATCH_SIZE) {
1159
+ if (options?.signal?.aborted) {
1160
+ throw new GislAbortError('multipartResume aborted');
1161
+ }
1162
+ const batch = missingParts.slice(i, i + PRESIGN_BATCH_SIZE);
1163
+ const presigned = await this.presignParts(status.uploadId, batch, status.totalParts, { signal: options?.signal });
1164
+ // Concurrent PUTs within the batch.
1165
+ const queue = [...presigned.presignedUrls];
1166
+ const workers = Array.from({ length: Math.min(this.multipartConcurrency, queue.length) }, async () => {
1167
+ while (queue.length > 0 && !failureController.signal.aborted) {
1168
+ if (options?.signal?.aborted) {
1169
+ throw new GislAbortError('multipartResume aborted');
1170
+ }
1171
+ const part = queue.shift();
1172
+ try {
1173
+ await putOne(part);
1174
+ }
1175
+ catch (err) {
1176
+ failureController.abort();
1177
+ throw err;
1178
+ }
1179
+ }
1180
+ });
1181
+ await Promise.all(workers);
1182
+ }
1183
+ }
1184
+ // Step 4: /complete with the FULL parts list = (server-recorded etags
1185
+ // from /status) ∪ (newly-PUT etags this run). Sort ascending by
1186
+ // partNumber (the wire shape pin in fresh-upload mirrors this).
1187
+ const allParts = [];
1188
+ for (const p of status.uploadedParts) {
1189
+ allParts.push({ partNumber: p.partNumber, etag: p.etag });
1190
+ }
1191
+ for (const e of newEtags)
1192
+ allParts.push(e);
1193
+ allParts.sort((a, b) => a.partNumber - b.partNumber);
1194
+ if (allParts.length !== status.totalParts) {
1195
+ throw new GislError(`multipartResume: assembled parts list has ${allParts.length} entries, ` +
1196
+ `expected ${status.totalParts}. Refusing to /complete with an incomplete part set.`);
1197
+ }
1198
+ // Marshal via the generator's `*ToJSON` helper so the contracts-drift
1199
+ // guard test (`contract-drift-fields.test.ts`) covers BOTH the fresh and
1200
+ // resume paths uniformly (code-reviewer P7). If a future regen adds a
1201
+ // required field to `MultipartCompleteRequest`, tsc fails here at the
1202
+ // typed object literal — same as the fresh path.
1203
+ const completeRequest = {
1204
+ uploadId: status.uploadId,
1205
+ parts: allParts.map((p) => ({ partNumber: p.partNumber, etag: p.etag })),
1206
+ };
1207
+ const wireCompleteBody = MultipartCompleteRequestToJSON(completeRequest);
1208
+ if (typeof wireCompleteBody?.upload_id !== 'string' ||
1209
+ !Array.isArray(wireCompleteBody?.parts)) {
1210
+ throw new GislError('multipartResume: MultipartCompleteRequestToJSON returned an unexpected shape.');
1211
+ }
1212
+ const completeResp = await this.request('POST', '/api/uploads/multipart/complete', {
1213
+ body: wireCompleteBody,
1214
+ deserialize: MultipartCompleteResponseFromJSON,
1215
+ signal: options?.signal,
1216
+ });
1217
+ if (completeResp.status !== 'completed') {
1218
+ throw new GislError(`multipartResume: completed with unexpected status: ${completeResp.status}`);
1219
+ }
1220
+ // Resume-path information loss: the /status envelope (and /complete)
1221
+ // do NOT carry `mime_type` or `constraints_applied` — those were
1222
+ // emitted on the original initiate envelope, which the resume path
1223
+ // skipped. Fall back to caller-supplied `fileName` for `originalName`;
1224
+ // emit `mimeType` as `''` and `constraintsApplied` as a sentinel
1225
+ // populated with the only fact we DO know on resume: `maxSizeBytes =
1226
+ // totalSize` (the upload was permitted at this size when initiated),
1227
+ // `processingClassPreAssignment = 'unknown'`. Consumers needing
1228
+ // authoritative post-upload metadata SHOULD call `getMetadata(fileId)`
1229
+ // (the fresh-upload path's docblock already says the same).
1230
+ // TODO(HxUmVr3Y): when contracts ships the resume-support schemas,
1231
+ // extend `/status` (or add `/multipart/{id}/manifest`) to carry
1232
+ // mime_type + constraints_applied so this sentinel can go away.
1233
+ return {
1234
+ fileId: completeResp.uploadId,
1235
+ originalName: fileName,
1236
+ mimeType: '',
1237
+ sizeBytes: totalSize,
1238
+ constraintsApplied: {
1239
+ maxSizeBytes: totalSize,
1240
+ // `maxDurationSeconds` deliberately omitted (not `null`): parity
1241
+ // comparator filters `undefined` keys from both sides; cross-SDK
1242
+ // upload_small precedent.
1243
+ processingClassPreAssignment: UploadConstraintsAppliedProcessingClassPreAssignmentEnum.unknown,
1244
+ },
1245
+ };
1246
+ }
1247
+ // -----------------------------------------------------------------------
1248
+ // SDK-3 (Wb6ebOMM) — resume-support endpoints
1249
+ // -----------------------------------------------------------------------
1250
+ /**
1251
+ * Fetch the durable status of an in-progress multipart upload session.
1252
+ *
1253
+ * Walks every page of `GET /api/uploads/multipart/{uploadId}/status`
1254
+ * (paginated via `next_part_number_marker` + `is_truncated`) and returns
1255
+ * the aggregated state. Callers see the complete set of recorded parts
1256
+ * across pages without driving the cursor themselves.
1257
+ *
1258
+ * Anonymous-initiated sessions return 403 → `GislMultipartSessionAuthRequiredError`.
1259
+ * Non-existent / expired sessions return 404 → `GislMultipartSessionNotFoundError`.
1260
+ * Authed-but-non-owning callers return 403 → `GislMultipartSessionOwnershipError`.
1261
+ *
1262
+ * TODO(HxUmVr3Y): replace hand-coded response shape on regen.
1263
+ */
1264
+ async getUploadStatus(uploadId, opts = {}) {
1265
+ if (typeof uploadId !== 'string' || uploadId === '') {
1266
+ throw new GislError('getUploadStatus: uploadId must be a non-empty string.');
1267
+ }
1268
+ return this.walkUploadStatus(uploadId, opts);
1269
+ }
1270
+ /**
1271
+ * Re-presign a batch of missing part numbers on an in-progress multipart
1272
+ * session.
1273
+ *
1274
+ * Validates client-side BEFORE the HTTP round-trip:
1275
+ * - `partNumbers` non-empty
1276
+ * - length <=100 (server raw-body cap is 8 KiB before json_decode)
1277
+ * - every entry an integer in `[2, totalParts]` — part 1 is sealed at
1278
+ * initiate (re-presigning it would break the etag recorded server-side
1279
+ * for /complete)
1280
+ * - entries unique
1281
+ * - `totalParts` <=10 000 (S3 hard limit; mirrors the SDK-1 ceiling guard)
1282
+ *
1283
+ * TODO(HxUmVr3Y): replace hand-coded request/response shapes on regen.
1284
+ */
1285
+ async presignParts(uploadId, partNumbers, totalParts, opts = {}) {
1286
+ if (typeof uploadId !== 'string' || uploadId === '') {
1287
+ throw new GislError('presignParts: uploadId must be a non-empty string.');
1288
+ }
1289
+ if (typeof totalParts !== 'number' ||
1290
+ !Number.isInteger(totalParts) ||
1291
+ totalParts < 1) {
1292
+ throw new GislError(`presignParts: totalParts must be a positive integer, got ${String(totalParts)}.`);
1293
+ }
1294
+ if (totalParts > S3_MAX_MULTIPART_PARTS) {
1295
+ throw new GislMultipartPartCountError(`presignParts: totalParts=${totalParts} exceeds the S3 ${S3_MAX_MULTIPART_PARTS}-part ` +
1296
+ 'multipart limit. Refusing to re-presign on a session that cannot complete.', totalParts, S3_MAX_MULTIPART_PARTS);
1297
+ }
1298
+ if (!Array.isArray(partNumbers) || partNumbers.length === 0) {
1299
+ throw new GislError('presignParts: partNumbers must be a non-empty array.');
1300
+ }
1301
+ if (partNumbers.length > 100) {
1302
+ throw new GislError(`presignParts: partNumbers has ${partNumbers.length} entries — server caps batches at 100.`);
1303
+ }
1304
+ const seen = new Set();
1305
+ for (const n of partNumbers) {
1306
+ if (typeof n !== 'number' ||
1307
+ !Number.isInteger(n) ||
1308
+ n < 2 ||
1309
+ n > totalParts) {
1310
+ throw new GislError(`presignParts: partNumbers entry ${String(n)} is not an integer in [2, ${totalParts}]. ` +
1311
+ 'Part 1 is sealed at initiate; re-presigning it would invalidate the recorded etag for /complete.');
1312
+ }
1313
+ if (seen.has(n)) {
1314
+ throw new GislError(`presignParts: partNumbers contains duplicate ${n}.`);
1315
+ }
1316
+ seen.add(n);
1317
+ }
1318
+ const path = `/api/uploads/multipart/${encodeURIComponent(uploadId)}/presign`;
1319
+ return this.request('POST', path, {
1320
+ // Hand-coded snake_case wire body. TODO(HxUmVr3Y): replace with
1321
+ // generated `*RequestToJSON` helper on regen.
1322
+ body: { part_numbers: [...partNumbers] },
1323
+ deserialize: (raw) => {
1324
+ // Hand-coded snake_case -> camelCase. TODO(HxUmVr3Y): replace with
1325
+ // generated FromJSON helper on regen.
1326
+ const r = raw;
1327
+ if (typeof r.upload_id !== 'string' || !Array.isArray(r.presigned_urls)) {
1328
+ throw new GislError('presignParts: malformed response envelope.');
1329
+ }
1330
+ return {
1331
+ uploadId: r.upload_id,
1332
+ presignedUrls: r.presigned_urls.map((p) => ({
1333
+ partNumber: p.part_number,
1334
+ url: p.url,
1335
+ expiresAt: p.expires_at,
1336
+ })),
1337
+ };
1338
+ },
1339
+ signal: opts.signal,
1340
+ });
1341
+ }
1342
+ /**
1343
+ * Extend the manifest TTL of an in-progress multipart upload session.
1344
+ *
1345
+ * The durable session manifest defaults to a 48 h TTL (decoupled from the
1346
+ * shorter presigned-URL TTL). For a long-running resume that spans days
1347
+ * (e.g. an upload paused overnight on flaky Wi-Fi), callers SHOULD invoke
1348
+ * `keepaliveUpload` every **12-24 h** while resuming — the 12-24 h band
1349
+ * leaves >=24 h of slack against the 48 h ceiling even with worst-case
1350
+ * clock skew between client and server. The server atomically refreshes
1351
+ * the Redis EXPIRE for the manifest key; the call is idempotent.
1352
+ *
1353
+ * TODO(HxUmVr3Y): replace hand-coded response shape on regen.
1354
+ */
1355
+ async keepaliveUpload(uploadId, opts = {}) {
1356
+ if (typeof uploadId !== 'string' || uploadId === '') {
1357
+ throw new GislError('keepaliveUpload: uploadId must be a non-empty string.');
1358
+ }
1359
+ const path = `/api/uploads/multipart/${encodeURIComponent(uploadId)}/keepalive`;
1360
+ return this.request('POST', path, {
1361
+ // Server expects an empty body; pass an empty object so the `request`
1362
+ // helper sets `Content-Type: application/json` for symmetry with the
1363
+ // other JSON-bodied POSTs. The endpoint ignores any fields if present.
1364
+ body: {},
1365
+ deserialize: (raw) => {
1366
+ // Hand-coded snake_case -> camelCase. TODO(HxUmVr3Y): replace with
1367
+ // generated FromJSON helper on regen.
1368
+ const r = raw;
1369
+ if (typeof r.upload_id !== 'string' ||
1370
+ typeof r.manifest_expires_at !== 'string') {
1371
+ throw new GislError('keepaliveUpload: malformed response envelope.');
1372
+ }
1373
+ return {
1374
+ uploadId: r.upload_id,
1375
+ manifestExpiresAt: r.manifest_expires_at,
1376
+ };
1377
+ },
1378
+ signal: opts.signal,
1379
+ });
1380
+ }
1381
+ /**
1382
+ * Private walk-pagination helper for /status. Aggregates every page into
1383
+ * a single `_Sdk3HandCodedMultipartStatusResult`. AbortSignal short-circuits
1384
+ * the loop between page fetches AND propagates into each fetch.
1385
+ *
1386
+ * Limit pinned to 1000 (max per page) so we make the minimum number of
1387
+ * round-trips even for the worst-case ~10 pages on a 10 000-part upload.
1388
+ */
1389
+ async walkUploadStatus(uploadId, opts) {
1390
+ const PAGE_LIMIT = 1000;
1391
+ // Slow-path DoS guard (code-reviewer minor 6). The cursor-advance check
1392
+ // already prevents an infinite loop; this cap additionally prevents a
1393
+ // pathological server that advances by 1 each page from forcing
1394
+ // O(totalParts) round-trips for a 10 000-part upload. PAGE_LIMIT=1000
1395
+ // means a healthy server completes in <=10 round-trips; 50 leaves
1396
+ // generous slack.
1397
+ const MAX_PAGES = 50;
1398
+ const collected = [];
1399
+ let cursor = 0;
1400
+ let totalParts = 0;
1401
+ let multipartUploadId = '';
1402
+ let cloudKey = '';
1403
+ let manifestExpiresAt = '';
1404
+ let recommendedChunkSize = 0;
1405
+ let pageCount = 0;
1406
+ while (true) {
1407
+ if (opts.signal?.aborted) {
1408
+ throw new GislAbortError('getUploadStatus aborted');
1409
+ }
1410
+ if (pageCount >= MAX_PAGES) {
1411
+ throw new GislError(`getUploadStatus: server returned more than ${MAX_PAGES} pages — refusing to ` +
1412
+ 'continue. The /status endpoint should advance the cursor in 1000-part strides.');
1413
+ }
1414
+ pageCount += 1;
1415
+ const query = `?cursor=${cursor}&limit=${PAGE_LIMIT}`;
1416
+ // String-concat the query OUTSIDE the path backtick so the contract-drift
1417
+ // scanner (tests/unit/contract-drift.test.ts) sees the bare path
1418
+ // `/api/uploads/multipart/{id}/status`. Embedding `${query}` in the
1419
+ // template collapses to `/status{id}` and false-drifts — same reason
1420
+ // getSchema and getCreditsUsage concatenate their querystrings.
1421
+ const path = `/api/uploads/multipart/${encodeURIComponent(uploadId)}/status` + query;
1422
+ const page = await this.request('GET', path, { signal: opts.signal });
1423
+ // Defensive: server contract pins these fields. Strict-validate every
1424
+ // top-level field on each page (code-reviewer P7) so a malformed wire
1425
+ // envelope cannot silently coerce a missing key to '' / 0 / NaN and
1426
+ // flow it into MultipartCheckpointState.manifestExpiresAt or downstream
1427
+ // chunkSize guards.
1428
+ if (typeof page.total_parts !== 'number' || page.total_parts < 1) {
1429
+ throw new GislError('getUploadStatus: server page missing or invalid total_parts.');
1430
+ }
1431
+ if (typeof page.upload_id !== 'string' ||
1432
+ page.upload_id !== uploadId ||
1433
+ typeof page.multipart_upload_id !== 'string' ||
1434
+ page.multipart_upload_id === '' ||
1435
+ typeof page.cloud_key !== 'string' ||
1436
+ page.cloud_key === '' ||
1437
+ typeof page.manifest_expires_at !== 'string' ||
1438
+ page.manifest_expires_at === '' ||
1439
+ typeof page.recommended_chunk_size !== 'number') {
1440
+ throw new GislError('getUploadStatus: server page missing required fields or returned a ' +
1441
+ `mismatching upload_id (expected ${uploadId}, got ` +
1442
+ `${String(page.upload_id)}).`);
1443
+ }
1444
+ totalParts = page.total_parts;
1445
+ multipartUploadId = page.multipart_upload_id;
1446
+ cloudKey = page.cloud_key;
1447
+ manifestExpiresAt = page.manifest_expires_at;
1448
+ recommendedChunkSize = page.recommended_chunk_size;
1449
+ for (const p of page.uploaded_parts ?? []) {
1450
+ collected.push({
1451
+ partNumber: p.part_number,
1452
+ etag: p.etag,
1453
+ sizeBytes: p.size_bytes,
1454
+ lastModified: p.last_modified,
1455
+ });
1456
+ }
1457
+ if (!page.is_truncated)
1458
+ break;
1459
+ // Advance cursor; guard against a contract-violating non-advancing
1460
+ // marker that would loop forever.
1461
+ if (typeof page.next_part_number_marker !== 'number' ||
1462
+ page.next_part_number_marker <= cursor) {
1463
+ throw new GislError('getUploadStatus: server is_truncated=true but next_part_number_marker ' +
1464
+ `did not advance (was ${cursor}, got ${String(page.next_part_number_marker)}).`);
1465
+ }
1466
+ cursor = page.next_part_number_marker;
1467
+ }
1468
+ // Sort ascending by partNumber — server SHOULD already deliver in order
1469
+ // page-by-page, but a defensive sort keeps the aggregated shape's
1470
+ // contract simple to consume (resume-branch missing-parts compute scans
1471
+ // it linearly).
1472
+ collected.sort((a, b) => a.partNumber - b.partNumber);
1473
+ return {
1474
+ uploadId,
1475
+ multipartUploadId,
1476
+ cloudKey,
1477
+ totalParts,
1478
+ uploadedParts: collected,
1479
+ manifestExpiresAt,
1480
+ recommendedChunkSize,
1481
+ };
1482
+ }
650
1483
  // -----------------------------------------------------------------------
651
1484
  // Workflows
652
1485
  // -----------------------------------------------------------------------
@@ -740,13 +1573,105 @@ export class GislClient {
740
1573
  /**
741
1574
  * Stream SSE events for a workflow. Returns an async iterable.
742
1575
  */
743
- async streamEvents(workflowId) {
1576
+ async streamEvents(workflowId, opts = {}) {
744
1577
  const eventsPath = `/api/workflows/${encodeURIComponent(workflowId)}/events`;
745
- const response = await this.request('GET', eventsPath, { rawResponse: true });
1578
+ // SSE-lifetime AbortController. `request()` builds its own controller
1579
+ // and tears it down (`clearTimeout(timer); unbind()`) in its `finally`
1580
+ // the instant the response headers arrive — BEFORE the SSE body
1581
+ // streams — so that controller cannot cancel a long-lived stream.
1582
+ // `streamEvents` must own a controller for the stream's whole lifetime.
1583
+ // We pass its signal to `request()` too, so a pre-aborted signal /
1584
+ // connect-phase abort still fast-fails. After headers, the live socket
1585
+ // is freed only by `reader.cancel()` inside `parseSseStream` — driven
1586
+ // by aborting this controller from the iterator wrapper's
1587
+ // `return()`/`throw()` (a generator's own `return()` is unreachable
1588
+ // while suspended at `await reader.read()`; canonical pattern:
1589
+ // openai-node `Stream[Symbol.asyncIterator]` + PR #1314).
1590
+ const controller = new AbortController();
1591
+ // Compose an optional consumer-supplied signal onto our controller.
1592
+ // The teardown MUST run (normal completion, error, OR early return)
1593
+ // or a long-lived consumer AbortController leaks listeners.
1594
+ const releaseConsumerSignal = bindAbortSignal(opts.signal, controller);
1595
+ let response;
1596
+ try {
1597
+ response = await this.request('GET', eventsPath, {
1598
+ rawResponse: true,
1599
+ signal: controller.signal,
1600
+ });
1601
+ }
1602
+ catch (err) {
1603
+ releaseConsumerSignal();
1604
+ throw err;
1605
+ }
746
1606
  if (!response.ok) {
747
- await this.handleResponse(response, eventsPath);
1607
+ try {
1608
+ await this.handleResponse(response, eventsPath); // always throws
1609
+ }
1610
+ finally {
1611
+ releaseConsumerSignal();
1612
+ }
748
1613
  }
749
- return parseSseStream(response);
1614
+ const inner = parseSseStream(response, { signal: controller.signal });
1615
+ let started = false;
1616
+ let settled = false;
1617
+ // Idempotent teardown. `abort` only on consumer-driven early
1618
+ // termination (return/throw) — NOT on normal completion or stream
1619
+ // error, where aborting would be a spurious "aborted though it
1620
+ // wasn't" signal (openai-node#194). If the consumer disposes the
1621
+ // iterator before ever pulling an event, the inner generator never
1622
+ // ran, so its `finally` won't cancel the body — cancel it here as a
1623
+ // backstop (the body is still unlocked: no reader was acquired).
1624
+ const cleanup = (abort) => {
1625
+ if (settled)
1626
+ return;
1627
+ settled = true;
1628
+ if (abort)
1629
+ controller.abort();
1630
+ if (!started)
1631
+ void response.body?.cancel().catch(() => { });
1632
+ releaseConsumerSignal();
1633
+ };
1634
+ // Abort-before-first-pull backstop. If the consumer aborts (their
1635
+ // signal, composed onto `controller`) and then drops the iterator
1636
+ // WITHOUT ever calling next()/return()/throw(), nothing else frees the
1637
+ // already-fetched body: `request()` unbound its fetch controller at
1638
+ // header receipt, and `parseSseStream` only attaches its reader +
1639
+ // abort listener once iteration starts. `cleanup`'s `!started` branch
1640
+ // only runs from the wrapper methods, so it never fires on a pure
1641
+ // abort-and-drop. Cancel the (still-unlocked) body directly here.
1642
+ // Once started, `parseSseStream` owns the locked reader and cancels
1643
+ // via its own abort listener, so this no-ops.
1644
+ controller.signal.addEventListener('abort', () => {
1645
+ if (!started)
1646
+ void response.body?.cancel().catch(() => { });
1647
+ }, { once: true });
1648
+ const wrapper = {
1649
+ async next(...args) {
1650
+ started = true;
1651
+ try {
1652
+ const result = await inner.next(...args);
1653
+ if (result.done)
1654
+ cleanup(false);
1655
+ return result;
1656
+ }
1657
+ catch (err) {
1658
+ cleanup(false);
1659
+ throw err;
1660
+ }
1661
+ },
1662
+ async return(value) {
1663
+ cleanup(true);
1664
+ return inner.return(value);
1665
+ },
1666
+ async throw(err) {
1667
+ cleanup(true);
1668
+ return inner.throw(err);
1669
+ },
1670
+ [Symbol.asyncIterator]() {
1671
+ return this;
1672
+ },
1673
+ };
1674
+ return wrapper;
750
1675
  }
751
1676
  // -----------------------------------------------------------------------
752
1677
  // File metadata
@@ -771,6 +1696,16 @@ export class GislClient {
771
1696
  * previous response to revalidate — a 304 surfaces as
772
1697
  * `{ notModified: true, etag, lastModified }` so callers can keep
773
1698
  * using their cached copy.
1699
+ *
1700
+ * NOTE: this schema describes available types/options/availability — it does
1701
+ * NOT carry per-tier processing-class size/duration caps (the response shape
1702
+ * has no `processing_class` / `per_tier_constraints`). The typed operation
1703
+ * metadata's `AvailabilityEntry.constraints` is the conservative baseline
1704
+ * only. Per-tier caps are enforced server-side: the caps that applied to an
1705
+ * upload are reported on a successful `UploadResponse.constraintsApplied`,
1706
+ * and an exceeded limit throws `GislUploadCapExceededError` (the 422 path
1707
+ * carries a typed payload; the 413 absolute-cap path is a plain envelope).
1708
+ * There is no read-ahead per-tier-cap API today.
774
1709
  */
775
1710
  async getSchema(options = {}) {
776
1711
  const params = new URLSearchParams();