@giveitsmaller/sdk 0.9.0 → 0.11.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 (42) hide show
  1. package/README.md +8 -0
  2. package/dist/_audit.js +0 -1
  3. package/dist/builder.js +65 -13
  4. package/dist/client.d.ts +38 -2
  5. package/dist/client.js +131 -7
  6. package/dist/credentials.js +4 -2
  7. package/dist/ergonomic/preset_resolver.js +4 -5
  8. package/dist/ergonomic/presets/image_compress.d.ts +1 -9
  9. package/dist/ergonomic/presets/image_compress.js +6 -25
  10. package/dist/ergonomic/presets/index.d.ts +1 -1
  11. package/dist/ergonomic/presets/index.js +1 -1
  12. package/dist/errors.d.ts +59 -1
  13. package/dist/errors.js +51 -0
  14. package/dist/file-first.d.ts +233 -0
  15. package/dist/file-first.js +585 -21
  16. package/dist/generated/sdk_spec/enums.d.ts +0 -11
  17. package/dist/generated/sdk_spec/enums.js +0 -7
  18. package/dist/generated/sdk_spec/errors.d.ts +1 -1
  19. package/dist/generated/sdk_spec/errors.js +26 -0
  20. package/dist/generated/sdk_spec/presets.js +0 -12
  21. package/dist/generated/sdk_spec/version.d.ts +2 -2
  22. package/dist/generated/sdk_spec/version.js +2 -2
  23. package/dist/handle.js +34 -14
  24. package/dist/index.browser.d.ts +1 -0
  25. package/dist/index.browser.js +14 -0
  26. package/dist/index.core.d.ts +35 -0
  27. package/dist/index.core.js +102 -0
  28. package/dist/index.d.ts +1 -32
  29. package/dist/index.js +9 -89
  30. package/dist/lazy-downloader.d.ts +19 -0
  31. package/dist/lazy-downloader.js +19 -0
  32. package/dist/merge.d.ts +11 -0
  33. package/dist/merge.js +144 -45
  34. package/dist/node-fs.browser.d.ts +17 -0
  35. package/dist/node-fs.browser.js +7 -0
  36. package/dist/node-fs.d.ts +14 -0
  37. package/dist/node-fs.js +14 -0
  38. package/dist/sha256.d.ts +20 -0
  39. package/dist/sha256.js +108 -0
  40. package/dist/types.d.ts +54 -2
  41. package/dist/types.js +2 -0
  42. package/package.json +15 -2
@@ -9,12 +9,16 @@
9
9
  *
10
10
  * Mirrors `packages/php/src/FileFirst/*`.
11
11
  */
12
- import { GislApiError, GislConfigError, GislNoSuchKeyError, GislSinkError, GislTimeoutError } from './errors.js';
12
+ import { GislConfigError, GislNetworkError, GislNoSuchKeyError, GislSinkError, GislTimeoutError, SseEndedWithoutTerminal } from './errors.js';
13
13
  import { _detectCompressMedia, _consumeSseToTerminal, _pollToTerminal, _parseMaxWait, _checkAborted, } from './builder.js';
14
- import { HttpDownloader } from './http-downloader.js';
14
+ import { LazyHttpDownloader } from './lazy-downloader.js';
15
15
  import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
16
16
  import { OptimizeFor } from './generated/sdk_spec/enums.js';
17
- import { uploadSource } from './types.js';
17
+ import { uploadSource, jobOutputSource } from './types.js';
18
+ // Value import used only at call-time (inside MergedRecipe.toWorkflowPayload),
19
+ // never at module-eval, so the file-first <-> merge <-> handle import cycle
20
+ // resolves cleanly under ESM (same deferred-usage discipline as `Handle`).
21
+ import { wireMergeOptions } from './merge.js';
18
22
  // Deferred-usage-only import: `Handle` is constructed inside `submit()` at call
19
23
  // time, never at module-eval, so the handle.ts <-> file-first.ts back-edge
20
24
  // (handle.ts imports RunResult/projectDownloadsToRunResult from here) resolves
@@ -299,6 +303,59 @@ export function isFanoutStatus(finalStatus) {
299
303
  const jobs = finalStatus.jobs ?? [];
300
304
  return jobs.length > 0 && jobs.every((job) => _FANOUT_REF.test(job.ref));
301
305
  }
306
+ const _MERGE_SRC_REF = /^src_\d+$/;
307
+ /**
308
+ * True when a terminal status describes a fluent `files([...]).merge(...)`
309
+ * combine — at least one job ref `merge` and every OTHER job ref is `src_{i}`
310
+ * (the ids the {@link MergedRecipe} lowering assigns). The data-driven seam that
311
+ * lets {@link Handle.wait}/{@link Handle.result} project ONLY the merged output
312
+ * — filtering the `src_*` passthrough plumbing — even after a
313
+ * `client.workflow(id)` reattach (no construction-time marker), matching
314
+ * {@link MergedRecipe.run}'s `ref === 'merge'` filter. Mutually exclusive with
315
+ * {@link isFanoutStatus} (a fan-out's refs are all `file-{i}`).
316
+ *
317
+ * @internal Exported for the file-first `Handle`; not part of the public API.
318
+ */
319
+ export function isMergeStatus(finalStatus) {
320
+ const jobs = finalStatus.jobs ?? [];
321
+ if (jobs.length === 0)
322
+ return false;
323
+ let hasMerge = false;
324
+ for (const job of jobs) {
325
+ if (job.ref === 'merge') {
326
+ hasMerge = true;
327
+ continue;
328
+ }
329
+ if (!_MERGE_SRC_REF.test(job.ref))
330
+ return false;
331
+ }
332
+ return hasMerge;
333
+ }
334
+ /**
335
+ * True when a terminal status describes a fluent `files([...]).archive(...)`
336
+ * bundle — at least one job ref `archive` and every OTHER job ref is `src_{i}`
337
+ * (the ids the {@link ArchivedRecipe} lowering assigns). Lets
338
+ * {@link Handle.wait}/{@link Handle.result} project ONLY the archive output —
339
+ * filtering the `src_*` passthrough plumbing — even after a `client.workflow(id)`
340
+ * reattach. Mutually exclusive with {@link isFanoutStatus} / {@link isMergeStatus}.
341
+ *
342
+ * @internal Exported for the file-first `Handle`; not part of the public API.
343
+ */
344
+ export function isArchiveStatus(finalStatus) {
345
+ const jobs = finalStatus.jobs ?? [];
346
+ if (jobs.length === 0)
347
+ return false;
348
+ let hasArchive = false;
349
+ for (const job of jobs) {
350
+ if (job.ref === 'archive') {
351
+ hasArchive = true;
352
+ continue;
353
+ }
354
+ if (!_MERGE_SRC_REF.test(job.ref))
355
+ return false;
356
+ }
357
+ return hasArchive;
358
+ }
302
359
  /** Named constructors for {@link FileInput} — mirror the PHP static factories. */
303
360
  export const fileInput = {
304
361
  path(path) {
@@ -307,6 +364,19 @@ export const fileInput = {
307
364
  blob(blob) {
308
365
  return { kind: 'blob', blob };
309
366
  },
367
+ /**
368
+ * Reference an already-uploaded file by its upload id, instead of
369
+ * re-uploading bytes.
370
+ *
371
+ * Auth-ownership: an upload created by an **authenticated** caller is owned
372
+ * by that caller. If you reuse the id from a client configured with a
373
+ * *different* auth context (a different `apiKey` / session), workflow-create
374
+ * returns `404 upload_not_found` — the server enforces ownership (api
375
+ * PqpD9ySv). Reference an upload id only under the SAME auth that created it.
376
+ * The normal upload-then-create-in-one-client flow is consistent by
377
+ * construction (the same `Authorization` rides every request). Ownerless
378
+ * (anonymous-intake) uploads are unaffected.
379
+ */
310
380
  uploadId(fileId) {
311
381
  return { kind: 'uploadId', fileId };
312
382
  },
@@ -452,18 +522,16 @@ export class Recipe {
452
522
  });
453
523
  }
454
524
  catch (err) {
455
- // Only genuine SSE transport / clean-stream-end failures fall through to
456
- // poll. Caller-deadline, abort, and API errors (a 401/402/etc. from
457
- // /events, or an onProgress callback throw surfacing as GislApiError)
458
- // MUST propagate re-issuing the same doomed request via poll would mask
459
- // them. Mirrors the PHP BuilderInternals::awaitTerminal sealed-marker
460
- // discipline (codex review medium).
461
- if (err instanceof GislTimeoutError)
462
- throw err;
463
- if (err instanceof DOMException && err.name === 'AbortError')
464
- throw err;
465
- if (err instanceof GislApiError)
525
+ // TDqmkWpX: poll-fallback ONLY on a clean SSE stream-end
526
+ // (SseEndedWithoutTerminal) or a typed transport error (GislNetworkError).
527
+ // Everything else — caller-deadline, abort, an API error from /events, an
528
+ // onProgress callback throw (propagates as-is, NOT wrapped), anything
529
+ // unexpected MUST propagate; re-issuing the same doomed request via poll
530
+ // would mask it. Mirrors the PHP BuilderInternals::awaitTerminal sealed-
531
+ // marker discipline.
532
+ if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
466
533
  throw err;
534
+ }
467
535
  finalStatus = await _pollToTerminal(this.client, {
468
536
  workflowId: created.workflowId,
469
537
  deadline,
@@ -477,9 +545,14 @@ export class Recipe {
477
545
  throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
478
546
  }
479
547
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
548
+ // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
549
+ // cannot return a success past the advertised maxWait deadline.
550
+ if (Date.now() >= deadline) {
551
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
552
+ }
480
553
  // Download URLs from getWorkflowDownloads are pre-signed and require no SDK
481
554
  // auth, so the downloader issues a plain unauthenticated fetch.
482
- const downloader = new HttpDownloader();
555
+ const downloader = new LazyHttpDownloader();
483
556
  return projectDownloadsToRunResult(created.workflowId, finalStatus, downloads.downloads, this.recipeKey ?? null, downloader);
484
557
  }
485
558
  /**
@@ -658,6 +731,40 @@ export class FilesRecipe {
658
731
  textWatermark(text) {
659
732
  return this.withStep(this.baseRecipe().textWatermark(text));
660
733
  }
734
+ /**
735
+ * Combine the inputs into ONE output (N→1), in array order (FF3b). Returns a
736
+ * single-output {@link MergedRecipe} you chain further ops on
737
+ * (`files([...]).merge().compress()`). Reuses the operation-first
738
+ * {@link MergeOptions} for the merge-level options, so the wire shape matches
739
+ * `client.merge([...], options)`.
740
+ *
741
+ * `merge()` must be the FIRST op on `files([...])` — per-file ops before a
742
+ * combine (compress-each-then-merge) are a separate follow-up, rejected here
743
+ * with `GislConfigError` reason `pre_merge_ops_unsupported`.
744
+ */
745
+ merge(options = {}) {
746
+ if (this.steps.length !== 0) {
747
+ throw new GislConfigError('merge() must be the first operation on files([...]); applying per-file ops before a combine ' +
748
+ '(compress-each-then-merge) is not yet supported — call merge() directly, then chain ops on the merged output.', { reason: 'pre_merge_ops_unsupported' });
749
+ }
750
+ return new MergedRecipe(this.inputs, options, [], this.presetDefaults, this.scopedPresetDefaults, this.client);
751
+ }
752
+ /**
753
+ * Bundle the inputs into ONE archive (N→1, zip / tar.gz) — media-agnostic,
754
+ * inputs may mix types. Returns a terminal {@link ArchivedRecipe} (a zip is
755
+ * the final artefact — no post-bundle chain). `format` / `folderStructure` are
756
+ * optional; the server defaults to zip + flat.
757
+ *
758
+ * `archive()` must be the FIRST op on `files([...])` → `GislConfigError` reason
759
+ * `pre_archive_ops_unsupported` otherwise.
760
+ */
761
+ archive(options = {}) {
762
+ if (this.steps.length !== 0) {
763
+ throw new GislConfigError('archive() must be the first operation on files([...]); applying per-file ops before a bundle ' +
764
+ 'is not yet supported — call archive() directly on the files you want to bundle.', { reason: 'pre_archive_ops_unsupported' });
765
+ }
766
+ return new ArchivedRecipe(this.inputs, options, this.client);
767
+ }
661
768
  /** The number of inputs in this fan-out (introspection / tests). */
662
769
  get inputCount() {
663
770
  return this.inputs.length;
@@ -725,12 +832,9 @@ export class FilesRecipe {
725
832
  });
726
833
  }
727
834
  catch (err) {
728
- if (err instanceof GislTimeoutError)
729
- throw err;
730
- if (err instanceof DOMException && err.name === 'AbortError')
731
- throw err;
732
- if (err instanceof GislApiError)
835
+ if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
733
836
  throw err;
837
+ }
734
838
  finalStatus = await _pollToTerminal(this.client, {
735
839
  workflowId: created.workflowId,
736
840
  deadline,
@@ -743,12 +847,17 @@ export class FilesRecipe {
743
847
  throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
744
848
  }
745
849
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
850
+ // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
851
+ // cannot return a success past the advertised maxWait deadline.
852
+ if (Date.now() >= deadline) {
853
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
854
+ }
746
855
  // keyByRef maps each job ref ("file-{i}") to the partition key. Today the
747
856
  // key is just the index string; the Map seam leaves room for the FF3b
748
857
  // keyed-fan-out card to map refs to caller-supplied keys without changing
749
858
  // the producer's signature.
750
859
  const keyByRef = new Map(this.inputs.map((_, i) => [`file-${i}`, String(i)]));
751
- const downloader = new HttpDownloader();
860
+ const downloader = new LazyHttpDownloader();
752
861
  return projectMultiJobToRunResult(created.workflowId, finalStatus, downloads.downloads, keyByRef, downloader);
753
862
  }
754
863
  /**
@@ -838,3 +947,458 @@ export class FilesRecipe {
838
947
  return new FilesRecipe(this.inputs, recipeWithStep.recipeSteps, this.presetDefaults, this.scopedPresetDefaults, this.client);
839
948
  }
840
949
  }
950
+ /**
951
+ * The single-output recipe you're in AFTER a fluent `files([...]).merge(...)`
952
+ * (FF3b). Merge collapses the N inputs into ONE output, so the per-file ops
953
+ * ({@link FilesRecipe.compress} etc.) no longer apply — instead this exposes the
954
+ * SAME chain ops as the single-file {@link Recipe}, applied to the merged
955
+ * result. `files([...]).merge().compress()` is the flagship case (example 14).
956
+ *
957
+ * **Lowering (one workflow):** each input is uploaded once and wrapped in its
958
+ * own single-input `passthrough` source job (`src_N`); the `merge` job consumes
959
+ * those via `job_output` inputs (array order = play order) and carries the merge
960
+ * op FIRST in its `operations[]`, followed by any post-combine ops (compress /
961
+ * convert / thumbnail) so they run on the merged output in the same job. The
962
+ * merge-level wire options reuse {@link wireMergeOptions} so a fluent merge
963
+ * lowers identically to the operation-first `client.merge()`.
964
+ *
965
+ * Immutable / clone-on-write like {@link Recipe} / {@link FilesRecipe}. Mirrors
966
+ * the PHP `MergedRecipe` in `packages/php/src/FileFirst/MergedRecipe.php`.
967
+ */
968
+ export class MergedRecipe {
969
+ inputs;
970
+ mergeOptions;
971
+ postSteps;
972
+ presetDefaults;
973
+ scopedPresetDefaults;
974
+ client;
975
+ constructor(inputs, mergeOptions, postSteps = [], presetDefaults, scopedPresetDefaults, client) {
976
+ this.inputs = inputs;
977
+ this.mergeOptions = mergeOptions;
978
+ this.postSteps = postSteps;
979
+ this.presetDefaults = presetDefaults;
980
+ this.scopedPresetDefaults = scopedPresetDefaults;
981
+ this.client = client;
982
+ }
983
+ /** Reduce the merged output's size. See {@link Recipe.compress}. */
984
+ compress(optimize) {
985
+ if (optimize !== undefined && !Object.values(OptimizeFor).includes(optimize)) {
986
+ const allowed = Object.values(OptimizeFor).join(', ');
987
+ throw new GislConfigError(`compress 'optimize' must be one of ${allowed}; got '${String(optimize)}'.`, { reason: 'invalid_optimize', conflictingFields: ['optimize'] });
988
+ }
989
+ return this.withStep({ opType: 'compress', options: optimize === undefined ? {} : { optimize } });
990
+ }
991
+ /** Change the merged output's format. See {@link Recipe.convert}. */
992
+ convert(format) {
993
+ return this.withStep({ opType: 'convert', options: { format } });
994
+ }
995
+ /** Thumbnail the merged output. Omitted dimensions are dropped from the wire options. */
996
+ thumbnail(options = {}) {
997
+ const wire = {};
998
+ if (options.width !== undefined)
999
+ wire.width = options.width;
1000
+ if (options.height !== undefined)
1001
+ wire.height = options.height;
1002
+ return this.withStep({ opType: 'thumbnail', options: wire });
1003
+ }
1004
+ /**
1005
+ * Lower to the merge DAG: one `passthrough` source job per input + one
1006
+ * `merge` job whose `operations[]` is `[merge, ...post-combine ops]`. The
1007
+ * merge job's `inputs[]` consume the source jobs via `job_output` in input
1008
+ * (play) order.
1009
+ *
1010
+ * @internal Consumed by {@link run} (after uploading all inputs), {@link submit}
1011
+ * (with a webhook), and the cross-language parity harness (with fixed ids).
1012
+ */
1013
+ toWorkflowPayload(fileIds, callbackUrl) {
1014
+ const mediaKind = this.inferMediaKind();
1015
+ const sourceJobs = [];
1016
+ const inputs = [];
1017
+ fileIds.forEach((fileId, i) => {
1018
+ const srcId = `src_${i}`;
1019
+ // Key order (id, source, operations) matches the PHP `toWire()` so the
1020
+ // JSON-string serialisation is byte-identical across languages.
1021
+ sourceJobs.push({ id: srcId, source: uploadSource(fileId), operations: [{ type: 'passthrough' }] });
1022
+ inputs.push({ source: jobOutputSource(srcId) });
1023
+ });
1024
+ const operations = [
1025
+ { type: 'merge', options: wireMergeOptions(this.mergeOptions, mediaKind) },
1026
+ ...this.lowerPostSteps(mediaKind),
1027
+ ];
1028
+ const mergeJob = { id: 'merge', inputs, operations };
1029
+ const jobs = [...sourceJobs, mergeJob];
1030
+ return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
1031
+ }
1032
+ /** The number of inputs being combined (introspection / tests). */
1033
+ get inputCount() {
1034
+ return this.inputs.length;
1035
+ }
1036
+ /** The number of post-combine ops chained so far (introspection / tests). */
1037
+ get stepCount() {
1038
+ return this.postSteps.length;
1039
+ }
1040
+ /**
1041
+ * Execute end-to-end: upload every input, create the merge workflow, await a
1042
+ * terminal state (SSE with poll fallback), then resolve ONLY the merged output
1043
+ * into a {@link RunResult}. Throws {@link GislTimeoutError} on `maxWait`.
1044
+ *
1045
+ * Requires a client bound at construction time — `gisl().files(...).merge(...)`
1046
+ * wires it; a directly-constructed `MergedRecipe` throws {@link GislConfigError}.
1047
+ * Mirrors the single-file {@link Recipe.run}.
1048
+ */
1049
+ async run(options = {}) {
1050
+ const signal = options.signal;
1051
+ const onProgress = options.onProgress;
1052
+ if (this.client === undefined) {
1053
+ throw new GislConfigError('MergedRecipe.run() requires a client; build the merge via gisl().files(...).merge(...) rather than constructing MergedRecipe directly.', { reason: 'no_client' });
1054
+ }
1055
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1056
+ const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal);
1057
+ let finalStatus;
1058
+ try {
1059
+ finalStatus = await _consumeSseToTerminal(this.client, {
1060
+ workflowId: created.workflowId,
1061
+ deadline,
1062
+ signal,
1063
+ onProgress,
1064
+ });
1065
+ }
1066
+ catch (err) {
1067
+ if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
1068
+ throw err;
1069
+ }
1070
+ finalStatus = await _pollToTerminal(this.client, {
1071
+ workflowId: created.workflowId,
1072
+ deadline,
1073
+ signal,
1074
+ pollIntervalMs: options.pollIntervalMs,
1075
+ });
1076
+ }
1077
+ if (Date.now() >= deadline) {
1078
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
1079
+ }
1080
+ const downloads = await this.client.getWorkflowDownloads(created.workflowId);
1081
+ // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
1082
+ // cannot return a success past the advertised maxWait deadline.
1083
+ if (Date.now() >= deadline) {
1084
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
1085
+ }
1086
+ // Project ONLY the merge job's output — the `src_*` passthrough jobs
1087
+ // re-expose the raw uploads, which are plumbing, not the deliverable
1088
+ // (mirrors the operation-first merge.ts `ref === 'merge'` filter + PHP).
1089
+ const mergeDownloads = downloads.downloads.filter((d) => d.ref === 'merge');
1090
+ const downloader = new LazyHttpDownloader();
1091
+ return projectDownloadsToRunResult(created.workflowId, finalStatus, mergeDownloads, null, downloader);
1092
+ }
1093
+ /**
1094
+ * Fire-and-forget: upload + create the merge workflow (wiring `webhook` into
1095
+ * `callback_url` when given), return a client-bound {@link Handle}. Does NOT
1096
+ * wait for terminal status. Mirrors {@link Recipe.submit}.
1097
+ */
1098
+ async submit(webhook) {
1099
+ if (this.client === undefined) {
1100
+ throw new GislConfigError('MergedRecipe.submit() requires a client; build the merge via gisl().files(...).merge(...) rather than constructing MergedRecipe directly.', { reason: 'no_client' });
1101
+ }
1102
+ const created = await this._uploadAllAndCreate(webhook, undefined);
1103
+ return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, null);
1104
+ }
1105
+ // ---------------------------------------------------------------------------
1106
+ /**
1107
+ * Upload every input (verbatim for a pre-uploaded id; uploading a path / blob
1108
+ * otherwise, emitting `{phase:'upload'}` progress) then create ONE merge
1109
+ * workflow. Rejects fewer than 2 inputs BEFORE any upload fires. Shared first
1110
+ * half of {@link run} + {@link submit}.
1111
+ */
1112
+ async _uploadAllAndCreate(webhook, deadline, onProgress, signal) {
1113
+ this.validatePreUpload();
1114
+ const fileIds = [];
1115
+ for (const input of this.inputs) {
1116
+ _checkAborted(signal);
1117
+ if (deadline !== undefined && Date.now() >= deadline) {
1118
+ throw new GislTimeoutError('maxWait elapsed during merge uploads before all inputs were uploaded');
1119
+ }
1120
+ if (input.kind === 'uploadId') {
1121
+ fileIds.push(input.fileId);
1122
+ }
1123
+ else {
1124
+ const source = input.kind === 'path' ? input.path : input.blob;
1125
+ const up = await this.client.uploadFile(source, {
1126
+ signal,
1127
+ ...(onProgress !== undefined
1128
+ ? {
1129
+ onProgress: (uploadedBytes, totalBytes) => {
1130
+ onProgress({ phase: 'upload', uploadedBytes, totalBytes });
1131
+ },
1132
+ }
1133
+ : {}),
1134
+ });
1135
+ fileIds.push(up.fileId);
1136
+ }
1137
+ }
1138
+ _checkAborted(signal);
1139
+ if (deadline !== undefined && Date.now() >= deadline) {
1140
+ throw new GislTimeoutError('Uploads completed but maxWait elapsed before the merge workflow could be created');
1141
+ }
1142
+ const created = await this.client.createWorkflow(this.toWorkflowPayload(fileIds, webhook));
1143
+ _checkAborted(signal);
1144
+ return created;
1145
+ }
1146
+ /**
1147
+ * Reject an invalid combine BEFORE any upload fires — mirrors the operation-
1148
+ * first `MergeBuilder.planSequence()` bounds so a typo'd merge costs no
1149
+ * bandwidth: 2–10 inputs (merge schema `min/max_inputs`), and an image merge
1150
+ * must carry an explicit `output_type` (the server rejects image merges
1151
+ * without one). Shared by {@link run} + {@link submit} via
1152
+ * {@link _uploadAllAndCreate}.
1153
+ */
1154
+ validatePreUpload() {
1155
+ if (this.inputs.length < 2) {
1156
+ throw new GislConfigError(`merge requires at least 2 inputs to combine (got ${this.inputs.length}).`, { reason: 'too_few_inputs' });
1157
+ }
1158
+ if (this.inputs.length > 10) {
1159
+ throw new GislConfigError(`merge accepts at most 10 inputs (got ${this.inputs.length}). Split the merge or reduce the input list.`, { reason: 'too_many_inputs' });
1160
+ }
1161
+ if (this.inferMediaKind() === 'image' &&
1162
+ this.mergeOptions.output === undefined &&
1163
+ this.mergeOptions.outputType === undefined) {
1164
+ throw new GislConfigError('image merges require an explicit output_type — set MergeOptions output: "video" | "gif" (or outputType). ' +
1165
+ 'The server rejects image merge requests with no output_type.', { reason: 'image_merge_requires_output_type' });
1166
+ }
1167
+ }
1168
+ /**
1169
+ * Lower the post-combine chain by composing a single-file {@link Recipe} over a
1170
+ * synthetic input whose extension matches the merged OUTPUT media — so
1171
+ * `compress(optimize)` resolves the correct preset for the merged result (it
1172
+ * needs a media hint, which a merge output carries no filename for). Reuses
1173
+ * Recipe's `lowerStep` rather than duplicating it.
1174
+ */
1175
+ lowerPostSteps(mediaKind) {
1176
+ if (this.postSteps.length === 0) {
1177
+ return [];
1178
+ }
1179
+ const synthetic = fileInput.path(`merged.${this.outputExtensionFor(mediaKind)}`);
1180
+ const recipe = new Recipe(synthetic, undefined, this.postSteps, this.presetDefaults, this.scopedPresetDefaults);
1181
+ return recipe.toWorkflowPayload('merged').jobs[0].operations;
1182
+ }
1183
+ /**
1184
+ * The merged-output media. Honours an explicit {@link MergeOptions.mediaKind};
1185
+ * otherwise infers from the first PATH input's extension (mirrors
1186
+ * {@link MergeBuilder}); defaults to video.
1187
+ */
1188
+ inferMediaKind() {
1189
+ if (this.mergeOptions.mediaKind !== undefined) {
1190
+ return this.mergeOptions.mediaKind;
1191
+ }
1192
+ // Sniff the first input carrying a media signal — a path extension or a
1193
+ // Blob MIME type (mirrors the operation-first MergeBuilder.inferMediaKind,
1194
+ // codex c2). Pre-uploaded ids carry no signal, so they are skipped.
1195
+ for (const input of this.inputs) {
1196
+ if (input.kind === 'path') {
1197
+ const lower = input.path.toLowerCase();
1198
+ if (/\.(jpe?g|png|webp|avif|gif|heic|tiff?)$/.test(lower))
1199
+ return 'image';
1200
+ if (/\.(mp3|wav|flac|aac|ogg|m4a)$/.test(lower))
1201
+ return 'audio';
1202
+ return 'video';
1203
+ }
1204
+ if (input.kind === 'blob') {
1205
+ if (input.blob.type.startsWith('image/'))
1206
+ return 'image';
1207
+ if (input.blob.type.startsWith('audio/'))
1208
+ return 'audio';
1209
+ return 'video';
1210
+ }
1211
+ }
1212
+ return 'video';
1213
+ }
1214
+ outputExtensionFor(mediaKind) {
1215
+ // An image merge produces a video/gif output (output_type), so the
1216
+ // post-combine media follows the output type when set.
1217
+ const output = this.mergeOptions.output ?? this.mergeOptions.outputType;
1218
+ if (mediaKind === 'image' && typeof output === 'string') {
1219
+ return output === 'gif' ? 'gif' : 'mp4';
1220
+ }
1221
+ switch (mediaKind) {
1222
+ case 'audio':
1223
+ return 'mp3';
1224
+ case 'image':
1225
+ return 'png';
1226
+ default:
1227
+ return 'mp4';
1228
+ }
1229
+ }
1230
+ withStep(step) {
1231
+ return new MergedRecipe(this.inputs, this.mergeOptions, [...this.postSteps, step], this.presetDefaults, this.scopedPresetDefaults, this.client);
1232
+ }
1233
+ }
1234
+ /**
1235
+ * The single-output recipe you're in AFTER a fluent `files([...]).archive(...)`
1236
+ * (FF3b). Archive bundles the N inputs into ONE downloadable archive (zip /
1237
+ * tar.gz) — media-agnostic, inputs may mix types. Unlike {@link MergedRecipe},
1238
+ * archive is TERMINAL: a zip is the final artefact, so there is no post-bundle
1239
+ * chain — this exposes only `run()` / `submit()`.
1240
+ *
1241
+ * **Lowering (one workflow):** each input is uploaded once and wrapped in its
1242
+ * own single-input `passthrough` source job (`src_N`); the `archive` job
1243
+ * consumes those via `job_output` inputs (array order = entry order) and carries
1244
+ * the single `archive` op. The archive job's id is `archive`, so {@link RunResult}
1245
+ * projects ONLY its output. Mirrors the PHP `ArchivedRecipe`.
1246
+ */
1247
+ export class ArchivedRecipe {
1248
+ inputs;
1249
+ options;
1250
+ client;
1251
+ constructor(inputs, options = {}, client) {
1252
+ this.inputs = inputs;
1253
+ this.options = options;
1254
+ this.client = client;
1255
+ }
1256
+ /** The number of inputs being bundled (introspection / tests). */
1257
+ get inputCount() {
1258
+ return this.inputs.length;
1259
+ }
1260
+ /**
1261
+ * Lower to the archive DAG: one `passthrough` source job per input + one
1262
+ * `archive` job consuming them via `job_output`.
1263
+ *
1264
+ * @internal Consumed by {@link run} / {@link submit} (after uploading) and the
1265
+ * cross-language parity harness (with fixed ids).
1266
+ */
1267
+ toWorkflowPayload(fileIds, callbackUrl) {
1268
+ const sourceJobs = [];
1269
+ const inputs = [];
1270
+ fileIds.forEach((fileId, i) => {
1271
+ const srcId = `src_${i}`;
1272
+ sourceJobs.push({ id: srcId, source: uploadSource(fileId), operations: [{ type: 'passthrough' }] });
1273
+ inputs.push({ source: jobOutputSource(srcId) });
1274
+ });
1275
+ const archiveJob = {
1276
+ id: 'archive',
1277
+ inputs,
1278
+ operations: [{ type: 'archive', options: this.wireArchiveOptions() }],
1279
+ };
1280
+ const jobs = [...sourceJobs, archiveJob];
1281
+ return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
1282
+ }
1283
+ /**
1284
+ * Execute end-to-end: upload every input, create the archive workflow, await a
1285
+ * terminal state (SSE with poll fallback), then resolve ONLY the archive output
1286
+ * into a {@link RunResult}. Throws {@link GislTimeoutError} on `maxWait`.
1287
+ * Requires a client bound via `gisl().files(...).archive(...)`.
1288
+ */
1289
+ async run(options = {}) {
1290
+ const signal = options.signal;
1291
+ const onProgress = options.onProgress;
1292
+ if (this.client === undefined) {
1293
+ throw new GislConfigError('ArchivedRecipe.run() requires a client; build the bundle via gisl().files(...).archive(...) rather than constructing ArchivedRecipe directly.', { reason: 'no_client' });
1294
+ }
1295
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1296
+ const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal);
1297
+ let finalStatus;
1298
+ try {
1299
+ finalStatus = await _consumeSseToTerminal(this.client, {
1300
+ workflowId: created.workflowId,
1301
+ deadline,
1302
+ signal,
1303
+ onProgress,
1304
+ });
1305
+ }
1306
+ catch (err) {
1307
+ if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
1308
+ throw err;
1309
+ }
1310
+ finalStatus = await _pollToTerminal(this.client, {
1311
+ workflowId: created.workflowId,
1312
+ deadline,
1313
+ signal,
1314
+ pollIntervalMs: options.pollIntervalMs,
1315
+ });
1316
+ }
1317
+ if (Date.now() >= deadline) {
1318
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
1319
+ }
1320
+ const downloads = await this.client.getWorkflowDownloads(created.workflowId);
1321
+ // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
1322
+ // cannot return a success past the advertised maxWait deadline.
1323
+ if (Date.now() >= deadline) {
1324
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
1325
+ }
1326
+ // Project ONLY the archive job's output — the `src_*` passthrough jobs
1327
+ // re-expose the raw uploads, which are plumbing, not the deliverable.
1328
+ const archiveDownloads = downloads.downloads.filter((d) => d.ref === 'archive');
1329
+ const downloader = new LazyHttpDownloader();
1330
+ return projectDownloadsToRunResult(created.workflowId, finalStatus, archiveDownloads, null, downloader);
1331
+ }
1332
+ /**
1333
+ * Fire-and-forget: upload + create the archive workflow (wiring `webhook` into
1334
+ * `callback_url` when given), return a client-bound {@link Handle}. Mirrors
1335
+ * {@link MergedRecipe.submit}.
1336
+ */
1337
+ async submit(webhook) {
1338
+ if (this.client === undefined) {
1339
+ throw new GislConfigError('ArchivedRecipe.submit() requires a client; build the bundle via gisl().files(...).archive(...) rather than constructing ArchivedRecipe directly.', { reason: 'no_client' });
1340
+ }
1341
+ const created = await this._uploadAllAndCreate(webhook, undefined);
1342
+ return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, null);
1343
+ }
1344
+ // ---------------------------------------------------------------------------
1345
+ async _uploadAllAndCreate(webhook, deadline, onProgress, signal) {
1346
+ this.validatePreUpload();
1347
+ const fileIds = [];
1348
+ for (const input of this.inputs) {
1349
+ _checkAborted(signal);
1350
+ if (deadline !== undefined && Date.now() >= deadline) {
1351
+ throw new GislTimeoutError('maxWait elapsed during archive uploads before all inputs were uploaded');
1352
+ }
1353
+ if (input.kind === 'uploadId') {
1354
+ fileIds.push(input.fileId);
1355
+ }
1356
+ else {
1357
+ const source = input.kind === 'path' ? input.path : input.blob;
1358
+ const up = await this.client.uploadFile(source, {
1359
+ signal,
1360
+ ...(onProgress !== undefined
1361
+ ? {
1362
+ onProgress: (uploadedBytes, totalBytes) => {
1363
+ onProgress({ phase: 'upload', uploadedBytes, totalBytes });
1364
+ },
1365
+ }
1366
+ : {}),
1367
+ });
1368
+ fileIds.push(up.fileId);
1369
+ }
1370
+ }
1371
+ _checkAborted(signal);
1372
+ if (deadline !== undefined && Date.now() >= deadline) {
1373
+ throw new GislTimeoutError('Uploads completed but maxWait elapsed before the archive workflow could be created');
1374
+ }
1375
+ const created = await this.client.createWorkflow(this.toWorkflowPayload(fileIds, webhook));
1376
+ _checkAborted(signal);
1377
+ return created;
1378
+ }
1379
+ /**
1380
+ * Reject an invalid bundle BEFORE any upload fires — the archive schema allows
1381
+ * 2–50 inputs (`min/max_inputs`), so a typo'd bundle costs no bandwidth.
1382
+ */
1383
+ validatePreUpload() {
1384
+ if (this.inputs.length < 2) {
1385
+ throw new GislConfigError(`archive requires at least 2 inputs to bundle (got ${this.inputs.length}).`, { reason: 'too_few_inputs' });
1386
+ }
1387
+ if (this.inputs.length > 50) {
1388
+ throw new GislConfigError(`archive accepts at most 50 inputs (got ${this.inputs.length}). Split the bundle or reduce the input list.`, { reason: 'too_many_inputs' });
1389
+ }
1390
+ }
1391
+ /**
1392
+ * Project the archive options into the wire shape. Both fields are optional
1393
+ * (the server defaults `format` to zip and `folder_structure` to flat), so an
1394
+ * omitted option is dropped rather than sent.
1395
+ */
1396
+ wireArchiveOptions() {
1397
+ const out = {};
1398
+ if (this.options.format !== undefined)
1399
+ out.format = this.options.format;
1400
+ if (this.options.folderStructure !== undefined)
1401
+ out.folder_structure = this.options.folderStructure;
1402
+ return out;
1403
+ }
1404
+ }