@giveitsmaller/sdk 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/gisl.js CHANGED
@@ -22,7 +22,8 @@ import { resolveApiKey, resolveEndpoint, } from './credentials.js';
22
22
  import { OperationBuilder } from './builder.js';
23
23
  import { MergeBuilder, asset } from './merge.js';
24
24
  import { PresetDefaults } from './ergonomic/presets/index.js';
25
- import { Recipe, fileInput } from './file-first.js';
25
+ import { Recipe, FilesRecipe, fileInput } from './file-first.js';
26
+ import { Handle } from './handle.js';
26
27
  // ---------------------------------------------------------------------------
27
28
  // Anonymous-capable operation allowlist (internal)
28
29
  // ---------------------------------------------------------------------------
@@ -88,6 +89,35 @@ function wrapErgonomic(client, presetDefaults, scopedPresetDefaults) {
88
89
  return new Recipe(resolved, key, [], presetDefaults, scopedPresetDefaults, target);
89
90
  };
90
91
  }
92
+ if (prop === 'files') {
93
+ // Homogeneous fan-out entry point (FF3a) — apply ONE recipe (op chain)
94
+ // to MANY input files in ONE workflow. Each element is coerced the same
95
+ // way `file()` coerces its single input: a bare string is a filesystem
96
+ // path, a Blob/File an in-memory input, a `FileInput` passed through.
97
+ // The fan-out's RunResult partitions per input by 0-based index.
98
+ return (inputs) => {
99
+ if (inputs.length === 0) {
100
+ // A zero-input fan-out is a caller error — "one failing input
101
+ // doesn't sink the rest" is meaningless with no inputs, and it
102
+ // would otherwise create an empty-jobs workflow the API 422s.
103
+ throw new GislConfigError('files() requires at least one input file.', {
104
+ reason: 'no_inputs',
105
+ });
106
+ }
107
+ const resolved = inputs.map((input) => typeof input === 'string'
108
+ ? fileInput.path(input)
109
+ : input instanceof Blob
110
+ ? fileInput.blob(input)
111
+ : input);
112
+ return new FilesRecipe(resolved, [], presetDefaults, scopedPresetDefaults, target);
113
+ };
114
+ }
115
+ if (prop === 'workflow') {
116
+ // Reattach to a previously-created workflow (FF5a). Returns a
117
+ // client-bound Handle with no webhookSecret and no recipe key —
118
+ // its RunResult is therefore keyless (succeeded[].key === null).
119
+ return (id) => new Handle(id, undefined, target);
120
+ }
91
121
  if (prop === 'compress' || prop === 'convert' || prop === 'thumbnail') {
92
122
  return (input, options = {}) => {
93
123
  // T4b — pass client-scope presetDefaults into the builder so
@@ -0,0 +1,153 @@
1
+ /**
2
+ * File-first {@link Handle} + {@link StatusSnapshot} value objects (FF5a).
3
+ *
4
+ * A `Handle` is the lightweight return of a fire-and-forget submit
5
+ * (`OperationBuilder.submit()` / `MergeBuilder.submit()`) AND the value
6
+ * `client.workflow(id)` hands back to reattach to a previously-created
7
+ * workflow. When a `Handle` carries a bound client it exposes three
8
+ * accessors:
9
+ *
10
+ * - `status()` — one non-blocking status fetch, projected to a
11
+ * {@link StatusSnapshot}.
12
+ * - `wait(maxWait, onProgress?)` — the ONLY blocking path: await terminal
13
+ * (SSE with poll fallback), then fetch downloads + project to a
14
+ * {@link RunResult}.
15
+ * - `result()` — non-blocking: fetch status once; if terminal, fetch
16
+ * downloads + project to a {@link RunResult}; if NOT terminal, throw
17
+ * {@link GislResultNotReadyError}. Never waits/polls.
18
+ *
19
+ * A `Handle` built WITHOUT a client (the operation-first/merge `submit()`
20
+ * path) keeps its data fields + `toJSON()` byte-identical to the prior
21
+ * `{ workflowId, webhookSecret }` interface; its accessors throw
22
+ * {@link GislConfigError} (reason `no_client`).
23
+ *
24
+ * Module placement: this lives in its OWN module (not `builder.ts` or
25
+ * `file-first.ts`) to keep the ESM import graph acyclic at module-load time.
26
+ * It imports the await-primitives from `builder.ts` and the
27
+ * {@link RunResult} + {@link projectDownloadsToRunResult} projection from
28
+ * `file-first.ts`; `builder.ts`/`merge.ts` import `Handle` back for
29
+ * construction inside their `submit()` methods. That back-edge is
30
+ * DEFERRED-USAGE-ONLY (construction happens at call time, not module-load),
31
+ * which ESM resolves cleanly.
32
+ *
33
+ * Mirrors the PHP `Gisl\Sdk\Ergonomic\Handle` + `Gisl\Sdk\Ergonomic\StatusSnapshot`.
34
+ */
35
+ import type { GislClient } from './client.js';
36
+ import { type ProgressEvent } from './builder.js';
37
+ import { RunResult } from './file-first.js';
38
+ /**
39
+ * A non-blocking snapshot of a workflow's lifecycle state, returned by
40
+ * {@link Handle.status}. `state` is the RAW wire `WorkflowStatus` value,
41
+ * verbatim (`pending` | `in_progress` | `completed` | `failed` |
42
+ * `partially_failed` | `paused_insufficient_credits` | `cancelled` |
43
+ * `expired`). There is NO `phase` field — phase is an SSE-only concept; the
44
+ * status response carries no phase.
45
+ *
46
+ * Mirrors the PHP `Gisl\Sdk\Ergonomic\StatusSnapshot`.
47
+ */
48
+ export declare class StatusSnapshot {
49
+ readonly workflowId: string;
50
+ readonly state: string;
51
+ constructor(workflowId: string, state: string);
52
+ /**
53
+ * True when {@link state} is one of the terminal states (`completed`,
54
+ * `failed`, `partially_failed`, `cancelled`, `expired`,
55
+ * `paused_insufficient_credits`); false for `pending` / `in_progress`.
56
+ */
57
+ isTerminal(): boolean;
58
+ /** Plain-object projection. Mirrors the PHP `toArray()`. */
59
+ toJSON(): {
60
+ workflowId: string;
61
+ state: string;
62
+ };
63
+ }
64
+ /**
65
+ * Handle to a created workflow. Carries `workflowId` + an optional
66
+ * `webhookSecret` (the data the operation-first/merge `submit()` returns)
67
+ * and, when reattached or built by the file-first run path, an optional
68
+ * bound {@link GislClient}.
69
+ *
70
+ * The bound client is OPTIONAL (mirrors how {@link RunResult} binds its
71
+ * {@link Downloader}): the data fields + {@link toJSON} stay byte-identical
72
+ * whether or not a client is present, so the operation-first/merge `submit()`
73
+ * back-compat fixture (`{ workflowId, webhookSecret }` via `toJSON()`) holds.
74
+ * When the client is absent, {@link status}/{@link wait}/{@link result} throw
75
+ * {@link GislConfigError} (reason `no_client`).
76
+ *
77
+ * A handle built via `client.workflow(id)` has NO recipe key, so its
78
+ * {@link RunResult} is keyless (`succeeded[].key === null`) — address its
79
+ * outputs positionally / via the sinks rather than `byKey()`.
80
+ *
81
+ * Mirrors the PHP `Gisl\Sdk\Ergonomic\Handle`.
82
+ */
83
+ export declare class Handle {
84
+ #private;
85
+ readonly workflowId: string;
86
+ readonly webhookSecret?: string | undefined;
87
+ constructor(workflowId: string, webhookSecret?: string | undefined, client?: GislClient, key?: string | null);
88
+ /**
89
+ * Fetch the workflow's current status once (non-blocking) and project it to
90
+ * a {@link StatusSnapshot}.
91
+ * @throws {GislConfigError} reason `no_client` when no client is bound.
92
+ */
93
+ status(): Promise<StatusSnapshot>;
94
+ /**
95
+ * Block until the workflow reaches a terminal state (SSE with poll
96
+ * fallback), then fetch its downloads and project to a {@link RunResult}.
97
+ * This is the ONLY blocking accessor on a `Handle`.
98
+ *
99
+ * @param maxWait Wall-clock deadline for the wait + downloads (string suffix
100
+ * `'2h'`/`'30m'`/`'120s'` or a number of milliseconds). Defaults to 300s,
101
+ * matching `Recipe.run()` / the PHP `Handle::wait()` default.
102
+ * @throws {GislConfigError} reason `no_client` when no client is bound.
103
+ * @throws {GislTimeoutError} when `maxWait` elapses before terminal.
104
+ */
105
+ wait(maxWait?: string | number, onProgress?: (event: ProgressEvent) => void): Promise<RunResult>;
106
+ /**
107
+ * Non-blocking result accessor. Fetches the workflow status once: if the
108
+ * workflow is terminal, fetches its downloads and projects to a
109
+ * {@link RunResult}; if it is NOT terminal, throws
110
+ * {@link GislResultNotReadyError}. Never waits or polls — use {@link wait}
111
+ * to block.
112
+ *
113
+ * @throws {GislConfigError} reason `no_client` when no client is bound.
114
+ * @throws {GislResultNotReadyError} when the workflow is not yet terminal.
115
+ */
116
+ result(): Promise<RunResult>;
117
+ /**
118
+ * Project a terminal status + its per-job downloads into a {@link RunResult},
119
+ * choosing the producer DATA-DRIVEN off the wire (not a construction-time
120
+ * marker, so a fan-out reattached via `client.workflow(id)` — which carries
121
+ * no marker — still partitions per job):
122
+ *
123
+ * - A `files([...])` fan-out (every job ref is `file-{i}`, see
124
+ * {@link isFanoutStatus}) → {@link projectMultiJobToRunResult} with an
125
+ * empty `keyByRef`, so each input's key is recovered from its `file-{i}`
126
+ * ref (`"0"`, `"1"`, …). A submitted/reattached fan-out carries no
127
+ * caller-supplied keys — keyed fan-out is a separate concern.
128
+ * - Anything else (the single-file {@link Recipe} path) →
129
+ * {@link projectDownloadsToRunResult} keyed by this handle's `#key`
130
+ * (the recipe key from a file-first `submit()`, or `null` on reattach).
131
+ */
132
+ private project;
133
+ /**
134
+ * Plain-object projection. Field order (`workflowId`, then `webhookSecret`
135
+ * when present) and the omit-when-undefined behaviour match the PHP
136
+ * `toArray()` so JSON-string parity holds with the prior `Handle` shape.
137
+ * The bound client is NEVER serialised.
138
+ */
139
+ toJSON(): {
140
+ workflowId: string;
141
+ webhookSecret?: string;
142
+ };
143
+ /**
144
+ * Back-compat alias for {@link toJSON} — the prior operation-first/merge
145
+ * `submit()` fixture asserts a plain `{ workflowId, webhookSecret }` shape.
146
+ */
147
+ toArray(): {
148
+ workflowId: string;
149
+ webhookSecret?: string;
150
+ };
151
+ private makeDownloader;
152
+ private requireClient;
153
+ }
package/dist/handle.js ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * File-first {@link Handle} + {@link StatusSnapshot} value objects (FF5a).
3
+ *
4
+ * A `Handle` is the lightweight return of a fire-and-forget submit
5
+ * (`OperationBuilder.submit()` / `MergeBuilder.submit()`) AND the value
6
+ * `client.workflow(id)` hands back to reattach to a previously-created
7
+ * workflow. When a `Handle` carries a bound client it exposes three
8
+ * accessors:
9
+ *
10
+ * - `status()` — one non-blocking status fetch, projected to a
11
+ * {@link StatusSnapshot}.
12
+ * - `wait(maxWait, onProgress?)` — the ONLY blocking path: await terminal
13
+ * (SSE with poll fallback), then fetch downloads + project to a
14
+ * {@link RunResult}.
15
+ * - `result()` — non-blocking: fetch status once; if terminal, fetch
16
+ * downloads + project to a {@link RunResult}; if NOT terminal, throw
17
+ * {@link GislResultNotReadyError}. Never waits/polls.
18
+ *
19
+ * A `Handle` built WITHOUT a client (the operation-first/merge `submit()`
20
+ * path) keeps its data fields + `toJSON()` byte-identical to the prior
21
+ * `{ workflowId, webhookSecret }` interface; its accessors throw
22
+ * {@link GislConfigError} (reason `no_client`).
23
+ *
24
+ * Module placement: this lives in its OWN module (not `builder.ts` or
25
+ * `file-first.ts`) to keep the ESM import graph acyclic at module-load time.
26
+ * It imports the await-primitives from `builder.ts` and the
27
+ * {@link RunResult} + {@link projectDownloadsToRunResult} projection from
28
+ * `file-first.ts`; `builder.ts`/`merge.ts` import `Handle` back for
29
+ * construction inside their `submit()` methods. That back-edge is
30
+ * DEFERRED-USAGE-ONLY (construction happens at call time, not module-load),
31
+ * which ESM resolves cleanly.
32
+ *
33
+ * Mirrors the PHP `Gisl\Sdk\Ergonomic\Handle` + `Gisl\Sdk\Ergonomic\StatusSnapshot`.
34
+ */
35
+ import { GislApiError, GislConfigError, GislResultNotReadyError, GislTimeoutError, } from './errors.js';
36
+ import { _consumeSseToTerminal, _pollToTerminal, _parseMaxWait, } from './builder.js';
37
+ import { projectDownloadsToRunResult, projectMultiJobToRunResult, isFanoutStatus, } from './file-first.js';
38
+ import { HttpDownloader } from './http-downloader.js';
39
+ /**
40
+ * The terminal workflow states. A status response in any of these states
41
+ * will not change without caller action. Mirrors the `TERMINAL_STATUSES`
42
+ * sets in `client.ts` / `WorkflowConstants` (PHP) — `paused_insufficient_credits`
43
+ * is treated as terminal because the workflow only resumes on caller action.
44
+ */
45
+ const TERMINAL_STATES = new Set([
46
+ 'completed',
47
+ 'failed',
48
+ 'partially_failed',
49
+ 'cancelled',
50
+ 'expired',
51
+ 'paused_insufficient_credits',
52
+ ]);
53
+ /**
54
+ * A non-blocking snapshot of a workflow's lifecycle state, returned by
55
+ * {@link Handle.status}. `state` is the RAW wire `WorkflowStatus` value,
56
+ * verbatim (`pending` | `in_progress` | `completed` | `failed` |
57
+ * `partially_failed` | `paused_insufficient_credits` | `cancelled` |
58
+ * `expired`). There is NO `phase` field — phase is an SSE-only concept; the
59
+ * status response carries no phase.
60
+ *
61
+ * Mirrors the PHP `Gisl\Sdk\Ergonomic\StatusSnapshot`.
62
+ */
63
+ export class StatusSnapshot {
64
+ workflowId;
65
+ state;
66
+ constructor(workflowId, state) {
67
+ this.workflowId = workflowId;
68
+ this.state = state;
69
+ }
70
+ /**
71
+ * True when {@link state} is one of the terminal states (`completed`,
72
+ * `failed`, `partially_failed`, `cancelled`, `expired`,
73
+ * `paused_insufficient_credits`); false for `pending` / `in_progress`.
74
+ */
75
+ isTerminal() {
76
+ return TERMINAL_STATES.has(this.state);
77
+ }
78
+ /** Plain-object projection. Mirrors the PHP `toArray()`. */
79
+ toJSON() {
80
+ return { workflowId: this.workflowId, state: this.state };
81
+ }
82
+ }
83
+ /**
84
+ * Handle to a created workflow. Carries `workflowId` + an optional
85
+ * `webhookSecret` (the data the operation-first/merge `submit()` returns)
86
+ * and, when reattached or built by the file-first run path, an optional
87
+ * bound {@link GislClient}.
88
+ *
89
+ * The bound client is OPTIONAL (mirrors how {@link RunResult} binds its
90
+ * {@link Downloader}): the data fields + {@link toJSON} stay byte-identical
91
+ * whether or not a client is present, so the operation-first/merge `submit()`
92
+ * back-compat fixture (`{ workflowId, webhookSecret }` via `toJSON()`) holds.
93
+ * When the client is absent, {@link status}/{@link wait}/{@link result} throw
94
+ * {@link GislConfigError} (reason `no_client`).
95
+ *
96
+ * A handle built via `client.workflow(id)` has NO recipe key, so its
97
+ * {@link RunResult} is keyless (`succeeded[].key === null`) — address its
98
+ * outputs positionally / via the sinks rather than `byKey()`.
99
+ *
100
+ * Mirrors the PHP `Gisl\Sdk\Ergonomic\Handle`.
101
+ */
102
+ export class Handle {
103
+ workflowId;
104
+ webhookSecret;
105
+ // True ES private (`#`), NOT a TS `private` modifier: a `private` constructor
106
+ // parameter property is an enumerable own field, so spreading/logging/Object
107
+ // .assign-ing a bound handle would leak the GislClient (incl. auth headers).
108
+ // `#client` is non-enumerable and inaccessible outside the class (codex high).
109
+ #client;
110
+ // The recipe's result-addressing key, threaded from a file-first `submit()`
111
+ // (`Recipe.submit()`) so the `RunResult` from `wait()`/`result()` is keyed
112
+ // (`succeeded[].key === recipeKey`). A reattached handle
113
+ // (`client.workflow(id)`) passes no key → null → keyless RunResult. It is
114
+ // ES-private (`#`) — like `#client` — NOT just kept out of `toJSON()`: the
115
+ // parity ReturnSerialiser enumerates a Handle's OWN ENUMERABLE properties
116
+ // (it does not call `toJSON()`), so a plain `readonly key` leaked into the
117
+ // operation-first/merge `submit()` back-compat shape ({workflowId,
118
+ // webhookSecret}). `#key` is non-enumerable, so that shape stays byte-identical.
119
+ #key;
120
+ constructor(workflowId, webhookSecret, client, key = null) {
121
+ this.workflowId = workflowId;
122
+ this.webhookSecret = webhookSecret;
123
+ this.#client = client;
124
+ this.#key = key;
125
+ }
126
+ /**
127
+ * Fetch the workflow's current status once (non-blocking) and project it to
128
+ * a {@link StatusSnapshot}.
129
+ * @throws {GislConfigError} reason `no_client` when no client is bound.
130
+ */
131
+ async status() {
132
+ const client = this.requireClient();
133
+ const status = await client.getWorkflowStatus(this.workflowId);
134
+ return new StatusSnapshot(this.workflowId, status.status);
135
+ }
136
+ /**
137
+ * Block until the workflow reaches a terminal state (SSE with poll
138
+ * fallback), then fetch its downloads and project to a {@link RunResult}.
139
+ * This is the ONLY blocking accessor on a `Handle`.
140
+ *
141
+ * @param maxWait Wall-clock deadline for the wait + downloads (string suffix
142
+ * `'2h'`/`'30m'`/`'120s'` or a number of milliseconds). Defaults to 300s,
143
+ * matching `Recipe.run()` / the PHP `Handle::wait()` default.
144
+ * @throws {GislConfigError} reason `no_client` when no client is bound.
145
+ * @throws {GislTimeoutError} when `maxWait` elapses before terminal.
146
+ */
147
+ async wait(maxWait = 300_000, onProgress) {
148
+ const client = this.requireClient();
149
+ const deadline = Date.now() + _parseMaxWait(maxWait);
150
+ let finalStatus;
151
+ try {
152
+ finalStatus = await _consumeSseToTerminal(client, {
153
+ workflowId: this.workflowId,
154
+ deadline,
155
+ signal: undefined,
156
+ onProgress,
157
+ });
158
+ }
159
+ catch (err) {
160
+ // Mirror Recipe.run(): only genuine SSE transport / clean-stream-end
161
+ // failures fall through to poll. Caller-deadline, abort, and API errors
162
+ // MUST propagate — re-issuing the same doomed request via poll would mask
163
+ // them.
164
+ if (err instanceof GislTimeoutError)
165
+ throw err;
166
+ if (err instanceof DOMException && err.name === 'AbortError')
167
+ throw err;
168
+ if (err instanceof GislApiError)
169
+ throw err;
170
+ finalStatus = await _pollToTerminal(client, {
171
+ workflowId: this.workflowId,
172
+ deadline,
173
+ signal: undefined,
174
+ });
175
+ }
176
+ if (Date.now() >= deadline) {
177
+ throw new GislTimeoutError(`Workflow ${this.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
178
+ }
179
+ const downloads = await client.getWorkflowDownloads(this.workflowId);
180
+ return this.project(finalStatus, downloads.downloads);
181
+ }
182
+ /**
183
+ * Non-blocking result accessor. Fetches the workflow status once: if the
184
+ * workflow is terminal, fetches its downloads and projects to a
185
+ * {@link RunResult}; if it is NOT terminal, throws
186
+ * {@link GislResultNotReadyError}. Never waits or polls — use {@link wait}
187
+ * to block.
188
+ *
189
+ * @throws {GislConfigError} reason `no_client` when no client is bound.
190
+ * @throws {GislResultNotReadyError} when the workflow is not yet terminal.
191
+ */
192
+ async result() {
193
+ const client = this.requireClient();
194
+ const status = await client.getWorkflowStatus(this.workflowId);
195
+ if (!TERMINAL_STATES.has(status.status)) {
196
+ throw new GislResultNotReadyError(this.workflowId, status.status);
197
+ }
198
+ const downloads = await client.getWorkflowDownloads(this.workflowId);
199
+ return this.project(status, downloads.downloads);
200
+ }
201
+ /**
202
+ * Project a terminal status + its per-job downloads into a {@link RunResult},
203
+ * choosing the producer DATA-DRIVEN off the wire (not a construction-time
204
+ * marker, so a fan-out reattached via `client.workflow(id)` — which carries
205
+ * no marker — still partitions per job):
206
+ *
207
+ * - A `files([...])` fan-out (every job ref is `file-{i}`, see
208
+ * {@link isFanoutStatus}) → {@link projectMultiJobToRunResult} with an
209
+ * empty `keyByRef`, so each input's key is recovered from its `file-{i}`
210
+ * ref (`"0"`, `"1"`, …). A submitted/reattached fan-out carries no
211
+ * caller-supplied keys — keyed fan-out is a separate concern.
212
+ * - Anything else (the single-file {@link Recipe} path) →
213
+ * {@link projectDownloadsToRunResult} keyed by this handle's `#key`
214
+ * (the recipe key from a file-first `submit()`, or `null` on reattach).
215
+ */
216
+ project(finalStatus, jobDownloads) {
217
+ const downloader = this.makeDownloader();
218
+ if (isFanoutStatus(finalStatus)) {
219
+ return projectMultiJobToRunResult(this.workflowId, finalStatus, jobDownloads, new Map(), downloader);
220
+ }
221
+ return projectDownloadsToRunResult(this.workflowId, finalStatus, jobDownloads, this.#key, downloader);
222
+ }
223
+ /**
224
+ * Plain-object projection. Field order (`workflowId`, then `webhookSecret`
225
+ * when present) and the omit-when-undefined behaviour match the PHP
226
+ * `toArray()` so JSON-string parity holds with the prior `Handle` shape.
227
+ * The bound client is NEVER serialised.
228
+ */
229
+ toJSON() {
230
+ return this.webhookSecret === undefined
231
+ ? { workflowId: this.workflowId }
232
+ : { workflowId: this.workflowId, webhookSecret: this.webhookSecret };
233
+ }
234
+ /**
235
+ * Back-compat alias for {@link toJSON} — the prior operation-first/merge
236
+ * `submit()` fixture asserts a plain `{ workflowId, webhookSecret }` shape.
237
+ */
238
+ toArray() {
239
+ return this.toJSON();
240
+ }
241
+ makeDownloader() {
242
+ // Download URLs from getWorkflowDownloads are pre-signed and require no SDK
243
+ // auth, so the downloader issues a plain unauthenticated fetch.
244
+ return new HttpDownloader();
245
+ }
246
+ requireClient() {
247
+ if (this.#client === undefined) {
248
+ throw new GislConfigError('This handle has no client bound, so it cannot query the workflow. ' +
249
+ 'Use recipe.run() to execute and get a RunResult directly, or reattach via client.workflow(id).', { reason: 'no_client' });
250
+ }
251
+ return this.#client;
252
+ }
253
+ }
package/dist/index.d.ts CHANGED
@@ -4,20 +4,22 @@ export { parseSseStream } from './sse.js';
4
4
  export type { CreditsUsageOptions, GetSchemaOptions, GetSchemaResult, PreflightClipError, PreflightClipsResult, GislClientConfig, GislSseEvent, UploadOptions, WaitOptions, WorkflowCreatePayload, OperationDef, WorkflowSourcePayload, UploadSourcePayload, JobOutputSourcePayload, ExternalImportSourcePayload, ConnectionSourcePayload, JobInputV2Payload, JobDefinitionPayload, ExternalDestinationPayload, DeliveryPayload, DeliveryModePayload, DeliveryBundleFormatPayload, DeliverySelectionPayload, DeliverySelectionTypePayload, DeliveryOutputRefPayload, WorkflowProcessingPayload, ProcessingClassHintPayload, MultipartCheckpointState, _Sdk3HandCodedUploadedPart, _Sdk3HandCodedMultipartStatusResult, _Sdk3HandCodedPresignedPart, _Sdk3HandCodedPresignPartsResult, _Sdk3HandCodedKeepaliveResult, } from './types.js';
5
5
  export { uploadSource, jobOutputSource, externalImportSource, connectionSource, } from './types.js';
6
6
  export type { GislConfigErrorMetadata } from './errors.js';
7
- export { GislError, GislApiError, GislValidationError, GislBalanceExhaustedError, GislTierRestrictedError, GislFeatureTierRestrictedError, GislFeatureNotAvailableError, GislWorkflowExpiredError, GislProbePendingError, GislAuthError, GislUploadCapExceededError, GislMultipartPartError, GislMultipartPartCountError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTimeoutError, GislAbortError, GislNetworkError, GislConfigError, GislMissingCredentialsError, GislFeatureRequiresAuthError, GislUndeclaredAssetError, GislUnusedAssetError, GislPerInputOptionsNotSupportedError, GislChainCardinalityMismatchError, GislNoSuchKeyError, GislSinkError, } from './errors.js';
7
+ export { GislError, GislApiError, GislValidationError, GislBalanceExhaustedError, GislTierRestrictedError, GislFeatureTierRestrictedError, GislFeatureNotAvailableError, GislWorkflowExpiredError, GislProbePendingError, GislAuthError, GislUploadCapExceededError, GislMultipartPartError, GislMultipartPartCountError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTimeoutError, GislAbortError, GislNetworkError, GislConfigError, GislMissingCredentialsError, GislFeatureRequiresAuthError, GislUndeclaredAssetError, GislUnusedAssetError, GislPerInputOptionsNotSupportedError, GislChainCardinalityMismatchError, GislNoSuchKeyError, GislSinkError, GislResultNotReadyError, } from './errors.js';
8
8
  export type { GislApiErrorOptions, GislUploadCapKind } from './errors.js';
9
9
  export { RunResult } from './file-first.js';
10
10
  export type { OutputFile, ItemResult, ItemFailure, Manifest, Downloader, } from './file-first.js';
11
11
  export { Recipe, fileInput } from './file-first.js';
12
12
  export type { FileInput } from './file-first.js';
13
+ export { FilesRecipe } from './file-first.js';
13
14
  export { HttpDownloader } from './http-downloader.js';
15
+ export { Handle, StatusSnapshot } from './handle.js';
14
16
  export { gisl, create } from './gisl.js';
15
17
  export type { GislCreateOptions, Environment, ErgonomicClient } from './gisl.js';
16
18
  export { presetDefaults, PresetDefaults, type PresetMedia, type PresetOp, type AnyPresetOptions, ImageCompressPresetOptions, type ImageCompressPresetOptionsInput, AudioCompressPresetOptions, type AudioCompressPresetOptionsInput, VideoCompressPresetOptions, type VideoCompressPresetOptionsInput, DocumentPdfCompressPresetOptions, type DocumentPdfCompressPresetOptionsInput, DocumentOfficeCompressPresetOptions, type DocumentOfficeCompressPresetOptionsInput, DocumentOdfCompressPresetOptions, type DocumentOdfCompressPresetOptionsInput, DocumentEpubCompressPresetOptions, type DocumentEpubCompressPresetOptionsInput, OptimizeFor, ImageMode, ImageFit, ImageMetadataPolicy, IccProfilePolicy, ImageFormat, VideoCodec, VideoPreset, VideoFit, AudioBitrate, AudioCodec, AudioSampleRate, PdfProfile, PdfColorspace, } from './ergonomic/presets/index.js';
17
19
  export { OperationBuilder, MapEachBuilder } from './builder.js';
18
20
  export { MergeBuilder, asset, handle, clip } from './merge.js';
19
21
  export type { Asset, ClipEntry, ClipOptions, MergeMediaKind, MergeOptions, SequenceEntry, } from './merge.js';
20
- export type { Artifact, ArtifactRef, Handle, JobBreakdown, OperationBreakdown, ProcessingProgressEvent, ProgressEvent, ResolvedOptions, ResolvedOptionsSources, Result, RunOptions, SubmitOptions, UploadProgressEvent, } from './builder.js';
22
+ export type { Artifact, ArtifactRef, JobBreakdown, OperationBreakdown, ProcessingProgressEvent, ProgressEvent, ResolvedOptions, ResolvedOptionsSources, Result, RunOptions, SubmitOptions, UploadProgressEvent, } from './builder.js';
21
23
  export { PRESET_VERSION, resolveCompressOptions } from './ergonomic/preset_resolver.js';
22
24
  export type { ResolveCompressOptionsInput, ResolveCompressOptionsOutput, } from './ergonomic/preset_resolver.js';
23
25
  export type { AudioWatermarkDecodeRequest, AudioWatermarkDecodeResponse, ContactRequest, CreditsBalanceResponse, CreditsUsageResponse, CreditTransaction, ExternalImportCreatedResponse, ExternalImportRequest, LoginUserRequest, LoginUser200ResponseData, LoginUser200ResponseDataUser, WorkflowCancelResponse, WorkflowResumeResponse, WorkflowPausedDetail, WorkflowPausedDetailLinks, UploadResponse, UploadConstraintsApplied, UploadProbeResponse, UploadProbeMediaMetadata, MultipartInitiateRequestMetadataHint, WorkflowCreateResponse, WorkflowStatusResponse, WorkflowDownloadResponse, MetadataResponse, MetadataResponseDimensions, MetadataResponseExif, MetadataResponseExifGps, OperationsSchemaResponse, OperationSchemaDefinition, MimeGroupSchema, OptionSchema, PerValueAvailabilityEntry, PerRoleCardinalityEntry, RetryResponse, JobDownload, OperationDownload, WebhookPayload, WebhookOperationContext, JobResponse, OperationResponse, OperationResult, OperationResultMetrics, ExternalDestination, Delivery, DeliveryPlan, DeliveryPlanOutput, WorkflowProcessing, ProcessingPlan, ProcessingPlanJob, WorkflowEdge, WorkflowWarning, JobInputV2, WorkflowSource, UploadSource, JobOutputSource, ConnectionSource, ExternalImportToken, BalanceExhaustedResponse, BalanceExhaustedResponseAllOfLinks, TierRestrictionResponse, FeatureTierRestrictedResponse, FeatureNotAvailableResponse, FeatureViolation, WorkflowExpiredResponse, ProbePendingResponse, AuthErrorResponse, } from '@giveitsmaller/contracts/openapi';
package/dist/index.js CHANGED
@@ -19,15 +19,31 @@ GislUndeclaredAssetError, GislUnusedAssetError, GislPerInputOptionsNotSupportedE
19
19
  // here so the future chain-method PR is a pure addition).
20
20
  GislChainCardinalityMismatchError,
21
21
  // FF1 / 3BIxEnfR — file-first result sink errors.
22
- GislNoSuchKeyError, GislSinkError, } from './errors.js';
22
+ GislNoSuchKeyError, GislSinkError,
23
+ // FF5a / Ao8RPVxD — thrown by the file-first Handle.result() when the
24
+ // workflow is not yet terminal (the non-blocking accessor).
25
+ GislResultNotReadyError, } from './errors.js';
23
26
  // File-first result surface (FF1 / 3BIxEnfR) — coexists with the
24
27
  // operation-first `Result`/`Artifact` until FF6 removes the old layer.
25
28
  export { RunResult } from './file-first.js';
26
29
  // File-first builder (FF2a / MfV0PDok) — `client.file(path).op()...` lowering.
27
30
  export { Recipe, fileInput } from './file-first.js';
31
+ // File-first homogeneous fan-out (FF3a / u0hBt6fl) — `client.files([...]).op()...`
32
+ // applies one recipe to many inputs in one workflow; run() partitions per input.
33
+ // `projectMultiJobToRunResult` is intentionally NOT re-exported — it is the
34
+ // @internal per-job producer consumed only by FilesRecipe.run (codex).
35
+ export { FilesRecipe } from './file-first.js';
28
36
  // File-first execution (FF2b / MfV0PDok) — Node streaming downloader bound by
29
37
  // `Recipe.run()` to write pre-signed output URLs to disk.
30
38
  export { HttpDownloader } from './http-downloader.js';
39
+ // `projectDownloadsToRunResult` is intentionally NOT re-exported here — it is an
40
+ // @internal helper shared between `file-first.ts` (Recipe.run) and `handle.ts`
41
+ // (Handle.wait/result) via direct intra-package import, not public API (codex).
42
+ // File-first Handle + StatusSnapshot (FF5a / Ao8RPVxD) — method-bearing,
43
+ // client-bound value objects. `Handle` is the return of `submit()` (no client)
44
+ // AND `client.workflow(id)` (client-bound, reattach). Exported as VALUES (the
45
+ // prior `export type { Handle }` is replaced) because `Handle` is now a class.
46
+ export { Handle, StatusSnapshot } from './handle.js';
31
47
  // Ergonomic-layer entrypoint (T1 / wVU4xHx3) — `gisl.create()` factory +
32
48
  // credential-chain types. `gisl.anonymous()` (public export) lands once
33
49
  // the anonymous-capable operation allowlist is non-empty (plan §12).
package/dist/merge.d.ts CHANGED
@@ -26,7 +26,8 @@
26
26
  * assets both fail fast so the caller saves bandwidth on typo'd composes.
27
27
  */
28
28
  import type { GislClient } from './client.js';
29
- import { type Handle, type Result, type RunOptions, type SubmitOptions } from './builder.js';
29
+ import { type Result, type RunOptions, type SubmitOptions } from './builder.js';
30
+ import { Handle } from './handle.js';
30
31
  /**
31
32
  * A declared merge asset. `path` carries a string or Blob (deduped by
32
33
  * normalised source key); `handle` wraps an already-uploaded file (deduped
package/dist/merge.js CHANGED
@@ -25,9 +25,10 @@
25
25
  * Local validation runs BEFORE any upload — undeclared refs and unused
26
26
  * assets both fail fast so the caller saves bandwidth on typo'd composes.
27
27
  */
28
- import { uploadSource } from './types.js';
28
+ import { uploadSource, jobOutputSource } from './types.js';
29
29
  import { GislConfigError, GislPerInputOptionsNotSupportedError, GislTimeoutError, GislUndeclaredAssetError, GislUnusedAssetError, } from './errors.js';
30
30
  import { _checkAborted, _consumeSseToTerminal, _parseMaxWait, _pollToTerminal, _projectResult, } from './builder.js';
31
+ import { Handle } from './handle.js';
31
32
  /**
32
33
  * Construct a path-asset. Bare-string arguments to `merge(...)` are
33
34
  * implicitly wrapped via this helper.
@@ -117,7 +118,14 @@ export class MergeBuilder {
117
118
  throw new GislTimeoutError(`Merge workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
118
119
  }
119
120
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
120
- return _projectResult(finalStatus, downloads.downloads, this.opOptionsForResolved());
121
+ // p0SuJEeK — project ONLY the merge job's output. getWorkflowDownloads
122
+ // returns a download group per terminal job, which now INCLUDES the
123
+ // `passthrough` source jobs (their output is the unchanged upload). Those
124
+ // are plumbing, not the merge deliverable — surfacing them as artifacts
125
+ // would pollute the Result with the raw inputs. The merge job's ref is
126
+ // 'merge' (see buildPayload); the source jobs are 'src_N'.
127
+ const mergeDownloads = downloads.downloads.filter((d) => d.ref === 'merge');
128
+ return _projectResult(finalStatus, mergeDownloads, this.opOptionsForResolved());
121
129
  }
122
130
  async submit(options) {
123
131
  const plan = this.planSequence();
@@ -125,11 +133,9 @@ export class MergeBuilder {
125
133
  const payload = this.buildPayload(plan, uploadedByAssetId);
126
134
  payload.callback_url = options.webhook;
127
135
  const created = await this.client.createWorkflow(payload);
128
- const handle = {
129
- workflowId: created.workflowId,
130
- ...(created.webhookSecret != null ? { webhookSecret: created.webhookSecret } : {}),
131
- };
132
- return handle;
136
+ // No client passed → the returned Handle's status()/wait()/result()
137
+ // throw `no_client`; the merge submit reconciles via webhook.
138
+ return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined);
133
139
  }
134
140
  // ---------------------------------------------------------------------------
135
141
  /**
@@ -247,12 +253,38 @@ export class MergeBuilder {
247
253
  return uploaded;
248
254
  }
249
255
  buildPayload(plan, uploadedByAssetId) {
250
- const inputs = plan.positions.map((pos) => {
256
+ // p0SuJEeK the API rejects upload-direct multi-input
257
+ // (`MultiInputSource` excludes the `upload` leaf: "use type=job_output").
258
+ // So each uploaded asset is wrapped in its OWN single-input `passthrough`
259
+ // source job, and the merge job references those via `job_output` — the
260
+ // shape the v2.35.0 `v2_merge_two_uploads` example prescribes. One source
261
+ // job per UNIQUE asset (in first-seen position order); a repeated asset
262
+ // re-uses its src job. `passthrough` is a lossless inert op (it does NOT
263
+ // get the implicit compress an empty `operations: []` job would).
264
+ const srcIdByAsset = new Map();
265
+ const sourceJobs = [];
266
+ for (const pos of plan.positions) {
267
+ if (srcIdByAsset.has(pos.assetId))
268
+ continue;
251
269
  const fileId = uploadedByAssetId.get(pos.assetId);
252
270
  if (fileId === undefined) {
253
271
  // Defensive — planSequence should have rejected this.
254
272
  throw new Error(`Asset '${pos.assetId}' was never uploaded — internal builder bug`);
255
273
  }
274
+ const srcId = `src_${sourceJobs.length}`;
275
+ srcIdByAsset.set(pos.assetId, srcId);
276
+ sourceJobs.push({
277
+ id: srcId,
278
+ source: uploadSource(fileId),
279
+ operations: [{ type: 'passthrough' }],
280
+ });
281
+ }
282
+ const inputs = plan.positions.map((pos) => {
283
+ // Defensive — srcIdByAsset was populated for every position's asset above.
284
+ const srcId = srcIdByAsset.get(pos.assetId);
285
+ if (srcId === undefined) {
286
+ throw new Error(`Asset '${pos.assetId}' has no source job — internal builder bug`);
287
+ }
256
288
  // Codex r1 HIGH 502c6bf232c2 — per_input_options goes on EACH
257
289
  // JobInputV2Payload (per-input entry), NOT on operations[0].options.
258
290
  // Skip emission for image merges (planSequence already rejects opts
@@ -261,7 +293,7 @@ export class MergeBuilder {
261
293
  const wireOpts = plan.mediaKind === 'image'
262
294
  ? {}
263
295
  : wirePerInputOptions(pos.options, plan.mediaKind);
264
- const input = { source: uploadSource(fileId) };
296
+ const input = { source: jobOutputSource(srcId) };
265
297
  if (Object.keys(wireOpts).length > 0) {
266
298
  input.per_input_options = wireOpts;
267
299
  }
@@ -269,12 +301,12 @@ export class MergeBuilder {
269
301
  });
270
302
  // Merge-level options (excluding the SDK-side mediaKind/allowUnusedAssets).
271
303
  const mergeOpts = wireMergeOptions(this.opOptions, plan.mediaKind);
272
- const job = {
304
+ const mergeJob = {
273
305
  id: 'merge',
274
306
  inputs,
275
307
  operations: [{ type: 'merge', options: mergeOpts }],
276
308
  };
277
- return { jobs: [job] };
309
+ return { jobs: [...sourceJobs, mergeJob] };
278
310
  }
279
311
  opOptionsForResolved() {
280
312
  // Strip the SDK-only fields before exposing on resolvedOptions.applied.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giveitsmaller/sdk",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Node.js SDK for the GISL (Give It Smaller) file compression and processing API",
5
5
  "license": "MIT",
6
6
  "type": "module",