@giveitsmaller/sdk 0.7.0 → 0.8.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/_audit.js CHANGED
@@ -121,4 +121,15 @@ export function _runAudit() {
121
121
  accept();
122
122
  accept();
123
123
  accept();
124
+ // FF1 / 3BIxEnfR — file-first result surface + sink errors.
125
+ accept();
126
+ accept();
127
+ accept();
128
+ accept();
129
+ accept();
130
+ accept();
131
+ accept();
132
+ accept();
133
+ accept();
134
+ accept();
124
135
  }
package/dist/client.js CHANGED
@@ -399,10 +399,19 @@ export class GislClient {
399
399
  locale: json.locale,
400
400
  messageParams: json.message_params,
401
401
  };
402
+ // Human-readable text comes from `message` (the I26 localised field).
403
+ // `error` is the stable, never-localised SCREAMING_SNAKE machine code —
404
+ // NOT display text. Surfacing `error` as the thrown error's `.message`
405
+ // regressed consumers that render the human string (x9Lbf6uy). Fall back
406
+ // to `error` when `message` is absent (deployed contract guarantees
407
+ // `message` on conforming error envelopes). Machine dispatch keys off
408
+ // `error_type` (below), unchanged.
409
+ const status = response.status;
410
+ const errorMessage = json.message ?? json.error ?? 'Unknown error';
402
411
  // Validation-details branch first — preserve existing shape so callers
403
412
  // matching on `instanceof GislValidationError` keep working.
404
413
  if (isValidationDetails(json.details)) {
405
- throw new GislValidationError(response.status, json.error ?? 'Validation error', json.details, path, i18n);
414
+ throw new GislValidationError(response.status, errorMessage, json.details, path, i18n);
406
415
  }
407
416
  // Dispatch by (status, error_type) onto the structured envelope shapes
408
417
  // emitted by the v2 contracts. Each branch builds the typed payload via
@@ -416,8 +425,6 @@ export class GislClient {
416
425
  // fall through to the base `GislApiError` rather than handing the
417
426
  // caller silently-corrupted typed metadata.
418
427
  const errorType = json.error_type;
419
- const status = response.status;
420
- const errorMessage = json.error ?? 'Unknown error';
421
428
  // Build the typed payload via FromJSON, then validate that all
422
429
  // required typed fields are well-formed. FromJSON does not throw on
423
430
  // missing required fields — for example `workflow_expired` without
@@ -1740,7 +1747,10 @@ export class GislClient {
1740
1747
  let errorMessage = 'Unknown error';
1741
1748
  try {
1742
1749
  const errJson = (await response.json());
1743
- if (errJson.error)
1750
+ // Prefer the human `message`; `error` is the machine code (x9Lbf6uy).
1751
+ if (errJson.message)
1752
+ errorMessage = errJson.message;
1753
+ else if (errJson.error)
1744
1754
  errorMessage = errJson.error;
1745
1755
  }
1746
1756
  catch {
package/dist/errors.d.ts CHANGED
@@ -300,6 +300,18 @@ export declare class GislChainCardinalityMismatchError extends GislConfigError {
300
300
  export declare class GislTimeoutError extends GislError {
301
301
  constructor(message: string);
302
302
  }
303
+ /**
304
+ * Transport-level failure: the underlying `fetch` (or other transport) could
305
+ * not produce a usable response — DNS, TCP, TLS, a mid-stream disconnect, or a
306
+ * non-ok status / empty body when fetching a result download. Mirrors the PHP
307
+ * `Gisl\Sdk\Errors\GislNetworkError`. Subclasses `GislError` (not
308
+ * `GislApiError`) because it carries no contract error envelope. The concrete
309
+ * file-first {@link Downloader} raises this when the output URL cannot be read
310
+ * (a destination-WRITE failure is `GislSinkError` reason `write_failed`).
311
+ */
312
+ export declare class GislNetworkError extends GislError {
313
+ constructor(message: string);
314
+ }
303
315
  export declare class GislAbortError extends GislError {
304
316
  constructor(message: string);
305
317
  }
@@ -326,3 +338,40 @@ export declare class GislMultipartPartCountError extends GislError {
326
338
  readonly maxParts: number;
327
339
  constructor(message: string, requiredParts: number, maxParts: number);
328
340
  }
341
+ /**
342
+ * Thrown by the file-first `RunResult.byKey()` (FF1) when no result entry
343
+ * matches the requested key. A keyless run (no `key:` supplied to `file()`)
344
+ * is addressable positionally only — `byKey()` always throws.
345
+ *
346
+ * Mirrors the PHP `Gisl\Sdk\Errors\GislNoSuchKeyError`.
347
+ */
348
+ export declare class GislNoSuchKeyError extends GislError {
349
+ constructor(message: string);
350
+ }
351
+ /** Machine-readable cause carried by {@link GislSinkError}. */
352
+ export type GislSinkErrorReason = 'not_single_output' | 'downloader_unavailable' | 'partial_failure' | 'duplicate_filename' | 'invalid_directory' | 'write_failed';
353
+ /**
354
+ * Thrown by the file-first `RunResult` sinks (`toFile()` / `downloadTo()`,
355
+ * FF1) when they cannot deliver. The machine-readable `reason` discriminates
356
+ * the three cases, mirroring the `reason`-bag convention on
357
+ * {@link GislConfigError}:
358
+ *
359
+ * - `not_single_output` — `toFile()` requires exactly one output but the
360
+ * run produced zero or more than one.
361
+ * - `downloader_unavailable` — the `RunResult` has no downloader bound (e.g. a
362
+ * browser / no-I/O context).
363
+ * - `partial_failure` — `downloadTo({ failOnPartial: true })` and the
364
+ * run had at least one failed input.
365
+ * - `duplicate_filename` — two outputs share a destination filename in one
366
+ * `downloadTo(dir)`, which would silently overwrite.
367
+ * - `write_failed` — a concrete {@link Downloader} could not open or
368
+ * stream to the destination path.
369
+ *
370
+ * Mirrors the PHP `Gisl\Sdk\Errors\GislSinkError`.
371
+ */
372
+ export declare class GislSinkError extends GislError {
373
+ readonly reason: GislSinkErrorReason;
374
+ constructor(message: string, options: {
375
+ readonly reason: GislSinkErrorReason;
376
+ });
377
+ }
package/dist/errors.js CHANGED
@@ -320,6 +320,21 @@ export class GislTimeoutError extends GislError {
320
320
  this.name = 'GislTimeoutError';
321
321
  }
322
322
  }
323
+ /**
324
+ * Transport-level failure: the underlying `fetch` (or other transport) could
325
+ * not produce a usable response — DNS, TCP, TLS, a mid-stream disconnect, or a
326
+ * non-ok status / empty body when fetching a result download. Mirrors the PHP
327
+ * `Gisl\Sdk\Errors\GislNetworkError`. Subclasses `GislError` (not
328
+ * `GislApiError`) because it carries no contract error envelope. The concrete
329
+ * file-first {@link Downloader} raises this when the output URL cannot be read
330
+ * (a destination-WRITE failure is `GislSinkError` reason `write_failed`).
331
+ */
332
+ export class GislNetworkError extends GislError {
333
+ constructor(message) {
334
+ super(message);
335
+ this.name = 'GislNetworkError';
336
+ }
337
+ }
323
338
  export class GislAbortError extends GislError {
324
339
  constructor(message) {
325
340
  super(message);
@@ -359,3 +374,43 @@ export class GislMultipartPartCountError extends GislError {
359
374
  this.maxParts = maxParts;
360
375
  }
361
376
  }
377
+ /**
378
+ * Thrown by the file-first `RunResult.byKey()` (FF1) when no result entry
379
+ * matches the requested key. A keyless run (no `key:` supplied to `file()`)
380
+ * is addressable positionally only — `byKey()` always throws.
381
+ *
382
+ * Mirrors the PHP `Gisl\Sdk\Errors\GislNoSuchKeyError`.
383
+ */
384
+ export class GislNoSuchKeyError extends GislError {
385
+ constructor(message) {
386
+ super(message);
387
+ this.name = 'GislNoSuchKeyError';
388
+ }
389
+ }
390
+ /**
391
+ * Thrown by the file-first `RunResult` sinks (`toFile()` / `downloadTo()`,
392
+ * FF1) when they cannot deliver. The machine-readable `reason` discriminates
393
+ * the three cases, mirroring the `reason`-bag convention on
394
+ * {@link GislConfigError}:
395
+ *
396
+ * - `not_single_output` — `toFile()` requires exactly one output but the
397
+ * run produced zero or more than one.
398
+ * - `downloader_unavailable` — the `RunResult` has no downloader bound (e.g. a
399
+ * browser / no-I/O context).
400
+ * - `partial_failure` — `downloadTo({ failOnPartial: true })` and the
401
+ * run had at least one failed input.
402
+ * - `duplicate_filename` — two outputs share a destination filename in one
403
+ * `downloadTo(dir)`, which would silently overwrite.
404
+ * - `write_failed` — a concrete {@link Downloader} could not open or
405
+ * stream to the destination path.
406
+ *
407
+ * Mirrors the PHP `Gisl\Sdk\Errors\GislSinkError`.
408
+ */
409
+ export class GislSinkError extends GislError {
410
+ reason;
411
+ constructor(message, options) {
412
+ super(message);
413
+ this.name = 'GislSinkError';
414
+ this.reason = options.reason;
415
+ }
416
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * File-first result surface — the value the file-first layer's `run()` /
3
+ * `Handle.wait()` / `Handle.result()` return (producers land in FF2b/FF5).
4
+ *
5
+ * Coexists with the operation-first `Result`/`Artifact` (in `builder.ts`)
6
+ * until the operation-first layer is removed (FF6). The file-first shape is
7
+ * flatter and adds an always-present per-input partition (`succeeded` /
8
+ * `failed`) so one bad input in a multi-input run doesn't sink the rest.
9
+ *
10
+ * Mirrors `packages/php/src/FileFirst/*`.
11
+ */
12
+ import { type ProgressEvent } from './builder.js';
13
+ import type { GislClient } from './client.js';
14
+ import { OptimizeFor } from './generated/sdk_spec/enums.js';
15
+ import type { PresetDefaults } from './ergonomic/presets/index.js';
16
+ import type { WorkflowCreatePayload } from './types.js';
17
+ /**
18
+ * Streams a single output URL to a local path. The seam between the
19
+ * file-first {@link RunResult} sinks and the SDK's HTTP/auth layer.
20
+ *
21
+ * FF1 defines ONLY this type — the concrete implementation (fetch +
22
+ * filesystem streamer) is wired by the producer tickets (`run()`/`submit()`,
23
+ * FF2b/FF5), which construct a `RunResult` with a real downloader bound to
24
+ * the client's auth context. Unit tests inject a small stub. A `RunResult`
25
+ * built WITHOUT a downloader (e.g. in a browser, or any no-I/O context)
26
+ * throws {@link GislSinkError} from its sinks rather than reaching for a
27
+ * global client.
28
+ *
29
+ * Mirrors the PHP `Downloader` interface.
30
+ *
31
+ * STREAMING CONTRACT: implementations MUST stream the URL body to
32
+ * `destPath` — they MUST NOT buffer the whole output in memory. The
33
+ * `Promise<void>` return exists precisely so no buffered-bytes value can
34
+ * leak into the calling convention.
35
+ */
36
+ export interface Downloader {
37
+ /**
38
+ * Stream the body at `url` to the local filesystem path `destPath`.
39
+ * Implementations create/overwrite `destPath`. Failures reject.
40
+ */
41
+ downloadTo(url: string, destPath: string): Promise<void>;
42
+ }
43
+ /**
44
+ * A single deliverable output of a file-first run — the file-first layer's
45
+ * flat output type. Leaner than the operation-first `Artifact`: just the
46
+ * four fields a caller needs to identify + fetch an output.
47
+ *
48
+ * Mirrors the PHP `OutputFile`.
49
+ */
50
+ export interface OutputFile {
51
+ readonly url: string;
52
+ readonly filename: string;
53
+ readonly sizeBytes: number;
54
+ readonly operation: string;
55
+ }
56
+ /**
57
+ * One succeeded entry in {@link RunResult.succeeded}: a single input's
58
+ * outputs, addressable by the `key:` the caller gave that file (null when
59
+ * no key was supplied). Mirrors the PHP `ItemResult`.
60
+ */
61
+ export interface ItemResult {
62
+ readonly key: string | null;
63
+ readonly outputs: readonly OutputFile[];
64
+ }
65
+ /**
66
+ * One failed entry in {@link RunResult.failed}: an input that did not
67
+ * produce a deliverable, paired with the cause. One bad input does not sink
68
+ * the rest of a multi-input run. `error` is `unknown` (mirroring the PHP
69
+ * `\Throwable`) so the caller narrows with `instanceof`. Mirrors the PHP
70
+ * `ItemFailure`.
71
+ */
72
+ export interface ItemFailure {
73
+ readonly key: string | null;
74
+ readonly error: unknown;
75
+ }
76
+ /**
77
+ * Return value of {@link RunResult.downloadTo} — the local paths written,
78
+ * in the SAME order as {@link RunResult.artifacts}. Mirrors the PHP
79
+ * `Manifest`.
80
+ */
81
+ export interface Manifest {
82
+ readonly paths: readonly string[];
83
+ }
84
+ /**
85
+ * Result of a file-first run. Coexists with the operation-first `Result`
86
+ * (in `builder.ts`) until FF6.
87
+ *
88
+ * Mirrors the PHP `RunResult` class. A class (not a bare interface) because
89
+ * it carries the `byKey()`/`toFile()`/`downloadTo()` behaviour; the data
90
+ * fields stay public + readonly so `toArray()` round-trips.
91
+ *
92
+ * Field notes:
93
+ * - `url`: single-output sugar — the lone artifact's URL when exactly one
94
+ * output exists, else undefined.
95
+ * - `ok`: true iff `failed` is empty. (A boolean — the partition lists are
96
+ * `succeeded`/`failed`; resolves the design doc's `ok` bool-vs-list
97
+ * contradiction.)
98
+ * - `state`: lifecycle state (`completed` | `failed` | ...). Named `state`,
99
+ * NOT `status`, matching the file-first `StatusSnapshot.state`.
100
+ * - sinks fetch via the injected {@link Downloader}; a result with no
101
+ * downloader throws {@link GislSinkError} (reason `downloader_unavailable`).
102
+ */
103
+ export declare class RunResult {
104
+ readonly workflowId: string;
105
+ readonly state: string;
106
+ readonly artifacts: readonly OutputFile[];
107
+ readonly succeeded: readonly ItemResult[];
108
+ readonly failed: readonly ItemFailure[];
109
+ private readonly downloader?;
110
+ /** Single-output sugar: the lone artifact's URL, or undefined for 0 / >1. */
111
+ readonly url?: string;
112
+ /** True iff {@link failed} is empty. */
113
+ readonly ok: boolean;
114
+ constructor(workflowId: string, state: string, artifacts: readonly OutputFile[], succeeded: readonly ItemResult[], failed: readonly ItemFailure[], downloader?: Downloader | undefined);
115
+ /**
116
+ * Address a succeeded input by the `key:` given to `file()`. Duplicate keys
117
+ * are not valid input — the producer enforces key uniqueness (a later
118
+ * ticket); the first match is returned.
119
+ * @throws {GislNoSuchKeyError} when no succeeded entry has that key (a
120
+ * keyless run always throws — it is positionally addressable only).
121
+ */
122
+ byKey(key: string): ItemResult;
123
+ /**
124
+ * Write the single output to `path`. Requires EXACTLY ONE artifact.
125
+ * @throws {GislSinkError} reason `not_single_output` for 0/>1 outputs;
126
+ * reason `downloader_unavailable` when no downloader is bound.
127
+ */
128
+ toFile(path: string): Promise<void>;
129
+ /**
130
+ * Download every output into `dir` (filename per output), in output order.
131
+ * Returns the {@link Manifest} of local paths written.
132
+ * @throws {GislSinkError} reason `partial_failure` when `failOnPartial` and
133
+ * the run had failed inputs; reason `downloader_unavailable` when no
134
+ * downloader is bound.
135
+ */
136
+ downloadTo(dir: string, options?: {
137
+ failOnPartial?: boolean;
138
+ }): Promise<Manifest>;
139
+ /**
140
+ * Plain-object projection. Field ORDER (workflowId, state, ok, url?,
141
+ * artifacts, succeeded, failed) is fixed to match the PHP `toArray()`
142
+ * reference so JSON-string parity holds (FF1 shape assertion + FF2b harness
143
+ * fixture). `url` is omitted entirely when undefined — `JSON.stringify`
144
+ * then produces the identical shape to PHP's omit-when-null `toArray()`.
145
+ */
146
+ toJSON(): {
147
+ workflowId: string;
148
+ state: string;
149
+ ok: boolean;
150
+ url?: string;
151
+ artifacts: readonly OutputFile[];
152
+ succeeded: readonly {
153
+ key: string | null;
154
+ outputs: readonly OutputFile[];
155
+ }[];
156
+ failed: readonly {
157
+ key: string | null;
158
+ error: string;
159
+ }[];
160
+ };
161
+ private requireDownloader;
162
+ }
163
+ /**
164
+ * The primary file a {@link Recipe} operates on — the "subject" of the
165
+ * file-first surface. A discriminated union over the ways a caller names an
166
+ * input:
167
+ *
168
+ * - `path` — a local filesystem path (Node; the common case).
169
+ * - `blob` — an in-memory `Blob`/`File` (browser, or Node 18+).
170
+ * - `uploadId` — a previously-uploaded `file_id` (reuse across recipes).
171
+ *
172
+ * FF2a does NO upload, so only the `path` + `uploadId` arms are exercised
173
+ * end-to-end here; the `blob` arm is DEFINED and type-checked but its upload
174
+ * is wired by FF2b (`run()`). Mirrors the PHP `FileInput` value object.
175
+ */
176
+ export type FileInput = {
177
+ readonly kind: 'path';
178
+ readonly path: string;
179
+ } | {
180
+ readonly kind: 'blob';
181
+ readonly blob: Blob;
182
+ } | {
183
+ readonly kind: 'uploadId';
184
+ readonly fileId: string;
185
+ };
186
+ /** Named constructors for {@link FileInput} — mirror the PHP static factories. */
187
+ export declare const fileInput: {
188
+ readonly path: (path: string) => FileInput;
189
+ readonly blob: (blob: Blob) => FileInput;
190
+ readonly uploadId: (fileId: string) => FileInput;
191
+ };
192
+ /** One step in a {@link Recipe}'s chain — an op kind + captured ergonomic args. */
193
+ interface RecipeStep {
194
+ readonly opType: 'compress' | 'convert' | 'thumbnail' | 'text_watermark';
195
+ readonly options: Readonly<Record<string, unknown>>;
196
+ }
197
+ /**
198
+ * The file-first builder value. `client.file(path)` returns a `Recipe`;
199
+ * single-input operations called on it (`compress`, `convert`, `thumbnail`,
200
+ * `textWatermark`) chain SEQUENTIALLY — each op feeds the next, and the chain
201
+ * lowers to ONE workflow job with an ordered `operations[]` (per ADR-0004:
202
+ * operations execute sequentially, each consuming the previous output). A
203
+ * chain yields the TERMINAL output only; intermediates are consumed (surfaced
204
+ * by FF2b's `run()`/{@link RunResult}).
205
+ *
206
+ * **Immutable / clone-on-write.** Every op returns a NEW `Recipe` carrying the
207
+ * appended step — `this` is never mutated. A Recipe is therefore a reusable
208
+ * value: branching the same base recipe two different ways cannot let one
209
+ * branch observe the other's steps (the aliasing trap mutable builders fall
210
+ * into).
211
+ *
212
+ * FF2a is network-free: there is NO `run()` here (that is FF2b). The lowering
213
+ * seam {@link toWorkflowPayload} takes the resolved upload id as a parameter
214
+ * so it stays pure — FF2b's `run()` calls the SAME method after uploading, and
215
+ * the parity harness calls it with a fixed id to assert the lowered shape.
216
+ *
217
+ * Mirrors the PHP `Recipe`.
218
+ */
219
+ export declare class Recipe {
220
+ private readonly input;
221
+ private readonly recipeKey;
222
+ private readonly steps;
223
+ private readonly presetDefaults?;
224
+ private readonly scopedPresetDefaults?;
225
+ private readonly client?;
226
+ constructor(input: FileInput, recipeKey?: string | undefined, steps?: readonly RecipeStep[], presetDefaults?: PresetDefaults | undefined, scopedPresetDefaults?: PresetDefaults | undefined, client?: GislClient | undefined);
227
+ /**
228
+ * Reduce file size. `optimize` selects a per-media preset (resolved to
229
+ * concrete wire fields at lower-time, exactly as `client.compress()` does).
230
+ */
231
+ compress(optimize?: OptimizeFor): Recipe;
232
+ /** Change format. `format` is lowered verbatim to the `format` wire option. */
233
+ convert(format: string): Recipe;
234
+ /**
235
+ * Generate a preview. Width and/or height in pixels; an omitted dimension is
236
+ * dropped from the wire options (not sent as `undefined`).
237
+ */
238
+ thumbnail(options?: {
239
+ width?: number;
240
+ height?: number;
241
+ }): Recipe;
242
+ /**
243
+ * Apply a text watermark. Single-input (the text is an option, not a
244
+ * secondary file) — lowers to the `text_watermark` op with a `text` option.
245
+ */
246
+ textWatermark(text: string): Recipe;
247
+ /**
248
+ * Lower this recipe to a workflow-create payload against a resolved upload
249
+ * id. Single-input chain → ONE job, `source: upload(fileId)`, ordered
250
+ * `operations[]`; the job `id` is omitted (a single job referenced by
251
+ * nothing — the server auto-assigns `job_N`).
252
+ *
253
+ * @internal Consumed by FF2b's `run()` (after a real upload) and by the
254
+ * cross-language parity harness (with a fixed id). Not part of the
255
+ * caller-facing fluent surface.
256
+ */
257
+ toWorkflowPayload(fileId: string): WorkflowCreatePayload;
258
+ /** The result-addressing key passed to `file()`, or undefined. */
259
+ key(): string | undefined;
260
+ /** The number of operations chained so far (introspection / tests). */
261
+ get stepCount(): number;
262
+ /**
263
+ * Execute the recipe end-to-end: upload the input (when required), create
264
+ * the workflow, await a terminal state (SSE with poll fallback), then
265
+ * resolve the produced downloads into a flat {@link RunResult}. Throws
266
+ * {@link GislTimeoutError} if `maxWait` elapses before terminal status.
267
+ *
268
+ * Mirrors the operation-first `OperationBuilder.run` (in `builder.ts`).
269
+ * Requires a client bound at construction time — `gisl().file(...)` wires
270
+ * it; a directly-constructed `Recipe` (e.g. in a lowering-only test) has no
271
+ * client and throws {@link GislConfigError}.
272
+ */
273
+ run(options?: {
274
+ maxWait?: string | number;
275
+ onProgress?: (event: ProgressEvent) => void;
276
+ signal?: AbortSignal;
277
+ pollIntervalMs?: number;
278
+ }): Promise<RunResult>;
279
+ private withStep;
280
+ private lowerStep;
281
+ private lowerCompressOptions;
282
+ private compressMediaHint;
283
+ }
284
+ export {};
@@ -0,0 +1,445 @@
1
+ /**
2
+ * File-first result surface — the value the file-first layer's `run()` /
3
+ * `Handle.wait()` / `Handle.result()` return (producers land in FF2b/FF5).
4
+ *
5
+ * Coexists with the operation-first `Result`/`Artifact` (in `builder.ts`)
6
+ * until the operation-first layer is removed (FF6). The file-first shape is
7
+ * flatter and adds an always-present per-input partition (`succeeded` /
8
+ * `failed`) so one bad input in a multi-input run doesn't sink the rest.
9
+ *
10
+ * Mirrors `packages/php/src/FileFirst/*`.
11
+ */
12
+ import { GislApiError, GislConfigError, GislNoSuchKeyError, GislSinkError, GislTimeoutError } from './errors.js';
13
+ import { _detectCompressMedia, _consumeSseToTerminal, _pollToTerminal, _parseMaxWait, _checkAborted, } from './builder.js';
14
+ import { HttpDownloader } from './http-downloader.js';
15
+ import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
16
+ import { OptimizeFor } from './generated/sdk_spec/enums.js';
17
+ import { uploadSource } from './types.js';
18
+ /**
19
+ * Result of a file-first run. Coexists with the operation-first `Result`
20
+ * (in `builder.ts`) until FF6.
21
+ *
22
+ * Mirrors the PHP `RunResult` class. A class (not a bare interface) because
23
+ * it carries the `byKey()`/`toFile()`/`downloadTo()` behaviour; the data
24
+ * fields stay public + readonly so `toArray()` round-trips.
25
+ *
26
+ * Field notes:
27
+ * - `url`: single-output sugar — the lone artifact's URL when exactly one
28
+ * output exists, else undefined.
29
+ * - `ok`: true iff `failed` is empty. (A boolean — the partition lists are
30
+ * `succeeded`/`failed`; resolves the design doc's `ok` bool-vs-list
31
+ * contradiction.)
32
+ * - `state`: lifecycle state (`completed` | `failed` | ...). Named `state`,
33
+ * NOT `status`, matching the file-first `StatusSnapshot.state`.
34
+ * - sinks fetch via the injected {@link Downloader}; a result with no
35
+ * downloader throws {@link GislSinkError} (reason `downloader_unavailable`).
36
+ */
37
+ export class RunResult {
38
+ workflowId;
39
+ state;
40
+ artifacts;
41
+ succeeded;
42
+ failed;
43
+ downloader;
44
+ /** Single-output sugar: the lone artifact's URL, or undefined for 0 / >1. */
45
+ url;
46
+ /** True iff {@link failed} is empty. */
47
+ ok;
48
+ constructor(workflowId, state, artifacts, succeeded, failed, downloader) {
49
+ this.workflowId = workflowId;
50
+ this.state = state;
51
+ this.artifacts = artifacts;
52
+ this.succeeded = succeeded;
53
+ this.failed = failed;
54
+ this.downloader = downloader;
55
+ this.url = artifacts.length === 1 ? artifacts[0].url : undefined;
56
+ this.ok = failed.length === 0;
57
+ }
58
+ /**
59
+ * Address a succeeded input by the `key:` given to `file()`. Duplicate keys
60
+ * are not valid input — the producer enforces key uniqueness (a later
61
+ * ticket); the first match is returned.
62
+ * @throws {GislNoSuchKeyError} when no succeeded entry has that key (a
63
+ * keyless run always throws — it is positionally addressable only).
64
+ */
65
+ byKey(key) {
66
+ const item = this.succeeded.find((i) => i.key === key);
67
+ if (item === undefined) {
68
+ throw new GislNoSuchKeyError(`No result for key '${key}'.`);
69
+ }
70
+ return item;
71
+ }
72
+ /**
73
+ * Write the single output to `path`. Requires EXACTLY ONE artifact.
74
+ * @throws {GislSinkError} reason `not_single_output` for 0/>1 outputs;
75
+ * reason `downloader_unavailable` when no downloader is bound.
76
+ */
77
+ async toFile(path) {
78
+ if (this.artifacts.length !== 1) {
79
+ throw new GislSinkError(`toFile() requires exactly one output; this run produced ${this.artifacts.length}. ` +
80
+ 'Use downloadTo() for multi-output runs.', { reason: 'not_single_output' });
81
+ }
82
+ await this.requireDownloader().downloadTo(this.artifacts[0].url, path);
83
+ }
84
+ /**
85
+ * Download every output into `dir` (filename per output), in output order.
86
+ * Returns the {@link Manifest} of local paths written.
87
+ * @throws {GislSinkError} reason `partial_failure` when `failOnPartial` and
88
+ * the run had failed inputs; reason `downloader_unavailable` when no
89
+ * downloader is bound.
90
+ */
91
+ async downloadTo(dir, options) {
92
+ if (options?.failOnPartial && this.failed.length > 0) {
93
+ throw new GislSinkError(`downloadTo({ failOnPartial: true }) but the run had ${this.failed.length} failed input(s).`, { reason: 'partial_failure' });
94
+ }
95
+ if (dir === '') {
96
+ throw new GislSinkError("downloadTo(): the directory argument is empty. Pass a target directory (use '.' for the current directory).", { reason: 'invalid_directory' });
97
+ }
98
+ const downloader = this.requireDownloader();
99
+ const sep = dir.endsWith('/') || dir.endsWith('\\') ? '' : '/';
100
+ // Resolve destinations first so a basename collision fails loudly BEFORE any
101
+ // byte is written — silently overwriting an earlier output is data loss.
102
+ const names = this.artifacts.map(
103
+ // Strip any directory component from a server-supplied filename so a value
104
+ // like "../x" or "a/b" cannot escape `dir` (mirrors PHP basename()).
105
+ (a) => a.filename.split(/[/\\]/).pop() ?? a.filename);
106
+ // Collision key is case-folded: many destination filesystems (macOS, NTFS)
107
+ // are case-insensitive, so `a.jpg` and `A.jpg` would target the same file.
108
+ const seen = new Set();
109
+ for (const name of names) {
110
+ const key = name.toLowerCase();
111
+ if (seen.has(key)) {
112
+ throw new GislSinkError(`downloadTo(): two outputs resolve to the same filename '${name}' in '${dir}' ` +
113
+ '(case-insensitively). Download them to separate directories.', { reason: 'duplicate_filename' });
114
+ }
115
+ seen.add(key);
116
+ }
117
+ const paths = [];
118
+ for (let i = 0; i < this.artifacts.length; i++) {
119
+ const dest = `${dir}${sep}${names[i]}`;
120
+ await downloader.downloadTo(this.artifacts[i].url, dest);
121
+ paths.push(dest);
122
+ }
123
+ return { paths };
124
+ }
125
+ /**
126
+ * Plain-object projection. Field ORDER (workflowId, state, ok, url?,
127
+ * artifacts, succeeded, failed) is fixed to match the PHP `toArray()`
128
+ * reference so JSON-string parity holds (FF1 shape assertion + FF2b harness
129
+ * fixture). `url` is omitted entirely when undefined — `JSON.stringify`
130
+ * then produces the identical shape to PHP's omit-when-null `toArray()`.
131
+ */
132
+ toJSON() {
133
+ // Re-project each OutputFile to exactly its four fields so structurally
134
+ // compatible inputs carrying extra properties can't leak into the JSON.
135
+ const file = (o) => ({
136
+ url: o.url,
137
+ filename: o.filename,
138
+ sizeBytes: o.sizeBytes,
139
+ operation: o.operation,
140
+ });
141
+ const rest = {
142
+ artifacts: this.artifacts.map(file),
143
+ succeeded: this.succeeded.map((i) => ({ key: i.key, outputs: i.outputs.map(file) })),
144
+ failed: this.failed.map((f) => ({
145
+ key: f.key,
146
+ error: f.error instanceof Error ? f.error.message : String(f.error),
147
+ })),
148
+ };
149
+ const head = { workflowId: this.workflowId, state: this.state, ok: this.ok };
150
+ // Insert `url` BETWEEN ok and artifacts when present, matching the PHP
151
+ // toArray() field order (workflowId, state, ok, url?, artifacts, ...) so
152
+ // JSON-string parity holds. Omitted entirely when undefined (PHP omits
153
+ // null), so `JSON.stringify` produces the identical shape.
154
+ return this.url === undefined
155
+ ? { ...head, ...rest }
156
+ : { ...head, url: this.url, ...rest };
157
+ }
158
+ requireDownloader() {
159
+ if (this.downloader === undefined) {
160
+ throw new GislSinkError('This result has no downloader bound, so its outputs cannot be written to disk here ' +
161
+ '(e.g. a browser / no-I/O context). Fetch each output from its URL instead.', { reason: 'downloader_unavailable' });
162
+ }
163
+ return this.downloader;
164
+ }
165
+ }
166
+ /** Named constructors for {@link FileInput} — mirror the PHP static factories. */
167
+ export const fileInput = {
168
+ path(path) {
169
+ return { kind: 'path', path };
170
+ },
171
+ blob(blob) {
172
+ return { kind: 'blob', blob };
173
+ },
174
+ uploadId(fileId) {
175
+ return { kind: 'uploadId', fileId };
176
+ },
177
+ };
178
+ /**
179
+ * The file-first builder value. `client.file(path)` returns a `Recipe`;
180
+ * single-input operations called on it (`compress`, `convert`, `thumbnail`,
181
+ * `textWatermark`) chain SEQUENTIALLY — each op feeds the next, and the chain
182
+ * lowers to ONE workflow job with an ordered `operations[]` (per ADR-0004:
183
+ * operations execute sequentially, each consuming the previous output). A
184
+ * chain yields the TERMINAL output only; intermediates are consumed (surfaced
185
+ * by FF2b's `run()`/{@link RunResult}).
186
+ *
187
+ * **Immutable / clone-on-write.** Every op returns a NEW `Recipe` carrying the
188
+ * appended step — `this` is never mutated. A Recipe is therefore a reusable
189
+ * value: branching the same base recipe two different ways cannot let one
190
+ * branch observe the other's steps (the aliasing trap mutable builders fall
191
+ * into).
192
+ *
193
+ * FF2a is network-free: there is NO `run()` here (that is FF2b). The lowering
194
+ * seam {@link toWorkflowPayload} takes the resolved upload id as a parameter
195
+ * so it stays pure — FF2b's `run()` calls the SAME method after uploading, and
196
+ * the parity harness calls it with a fixed id to assert the lowered shape.
197
+ *
198
+ * Mirrors the PHP `Recipe`.
199
+ */
200
+ export class Recipe {
201
+ input;
202
+ recipeKey;
203
+ steps;
204
+ presetDefaults;
205
+ scopedPresetDefaults;
206
+ client;
207
+ constructor(input, recipeKey = undefined, steps = [], presetDefaults, scopedPresetDefaults, client) {
208
+ this.input = input;
209
+ this.recipeKey = recipeKey;
210
+ this.steps = steps;
211
+ this.presetDefaults = presetDefaults;
212
+ this.scopedPresetDefaults = scopedPresetDefaults;
213
+ this.client = client;
214
+ }
215
+ /**
216
+ * Reduce file size. `optimize` selects a per-media preset (resolved to
217
+ * concrete wire fields at lower-time, exactly as `client.compress()` does).
218
+ */
219
+ compress(optimize) {
220
+ if (optimize !== undefined && !Object.values(OptimizeFor).includes(optimize)) {
221
+ const allowed = Object.values(OptimizeFor).join(', ');
222
+ throw new GislConfigError(`compress 'optimize' must be one of ${allowed}; got '${String(optimize)}'.`, { reason: 'invalid_optimize', conflictingFields: ['optimize'] });
223
+ }
224
+ return this.withStep({ opType: 'compress', options: optimize === undefined ? {} : { optimize } });
225
+ }
226
+ /** Change format. `format` is lowered verbatim to the `format` wire option. */
227
+ convert(format) {
228
+ return this.withStep({ opType: 'convert', options: { format } });
229
+ }
230
+ /**
231
+ * Generate a preview. Width and/or height in pixels; an omitted dimension is
232
+ * dropped from the wire options (not sent as `undefined`).
233
+ */
234
+ thumbnail(options = {}) {
235
+ const wire = {};
236
+ if (options.width !== undefined)
237
+ wire.width = options.width;
238
+ if (options.height !== undefined)
239
+ wire.height = options.height;
240
+ return this.withStep({ opType: 'thumbnail', options: wire });
241
+ }
242
+ /**
243
+ * Apply a text watermark. Single-input (the text is an option, not a
244
+ * secondary file) — lowers to the `text_watermark` op with a `text` option.
245
+ */
246
+ textWatermark(text) {
247
+ return this.withStep({ opType: 'text_watermark', options: { text } });
248
+ }
249
+ /**
250
+ * Lower this recipe to a workflow-create payload against a resolved upload
251
+ * id. Single-input chain → ONE job, `source: upload(fileId)`, ordered
252
+ * `operations[]`; the job `id` is omitted (a single job referenced by
253
+ * nothing — the server auto-assigns `job_N`).
254
+ *
255
+ * @internal Consumed by FF2b's `run()` (after a real upload) and by the
256
+ * cross-language parity harness (with a fixed id). Not part of the
257
+ * caller-facing fluent surface.
258
+ */
259
+ toWorkflowPayload(fileId) {
260
+ const operations = this.steps.map((step) => this.lowerStep(step));
261
+ // Key order (source, operations) matches the PHP `toWire()` so the
262
+ // JSON-string serialisation is byte-identical across languages.
263
+ const job = { source: uploadSource(fileId), operations };
264
+ return { jobs: [job] };
265
+ }
266
+ /** The result-addressing key passed to `file()`, or undefined. */
267
+ key() {
268
+ return this.recipeKey;
269
+ }
270
+ /** The number of operations chained so far (introspection / tests). */
271
+ get stepCount() {
272
+ return this.steps.length;
273
+ }
274
+ /**
275
+ * Execute the recipe end-to-end: upload the input (when required), create
276
+ * the workflow, await a terminal state (SSE with poll fallback), then
277
+ * resolve the produced downloads into a flat {@link RunResult}. Throws
278
+ * {@link GislTimeoutError} if `maxWait` elapses before terminal status.
279
+ *
280
+ * Mirrors the operation-first `OperationBuilder.run` (in `builder.ts`).
281
+ * Requires a client bound at construction time — `gisl().file(...)` wires
282
+ * it; a directly-constructed `Recipe` (e.g. in a lowering-only test) has no
283
+ * client and throws {@link GislConfigError}.
284
+ */
285
+ async run(options = {}) {
286
+ const signal = options.signal;
287
+ const onProgress = options.onProgress;
288
+ if (this.client === undefined) {
289
+ throw new GislConfigError('Recipe.run() requires a client; build the recipe via gisl().file(...) rather than constructing Recipe directly.', { reason: 'no_client' });
290
+ }
291
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
292
+ // 1. Resolve the upload id. A pre-uploaded id skips the upload entirely;
293
+ // a path / blob is uploaded now, emitting {phase:'upload'} progress.
294
+ let fileId;
295
+ if (this.input.kind === 'uploadId') {
296
+ fileId = this.input.fileId;
297
+ }
298
+ else {
299
+ const source = this.input.kind === 'path' ? this.input.path : this.input.blob;
300
+ const up = await this.client.uploadFile(source, {
301
+ signal,
302
+ ...(onProgress !== undefined
303
+ ? {
304
+ onProgress: (uploadedBytes, totalBytes) => {
305
+ onProgress({ phase: 'upload', uploadedBytes, totalBytes });
306
+ },
307
+ }
308
+ : {}),
309
+ });
310
+ fileId = up.fileId;
311
+ }
312
+ _checkAborted(signal);
313
+ if (Date.now() >= deadline) {
314
+ throw new GislTimeoutError('Upload completed but maxWait elapsed before workflow could be created');
315
+ }
316
+ // 2. Create the workflow from the lowered payload.
317
+ const payload = this.toWorkflowPayload(fileId);
318
+ const created = await this.client.createWorkflow(payload);
319
+ _checkAborted(signal);
320
+ // 3. Wait to terminal status — SSE first, poll on a genuine SSE error.
321
+ // Caller-aborted + deadline-elapsed errors MUST propagate (not transient).
322
+ let finalStatus;
323
+ try {
324
+ finalStatus = await _consumeSseToTerminal(this.client, {
325
+ workflowId: created.workflowId,
326
+ deadline,
327
+ signal,
328
+ onProgress,
329
+ });
330
+ }
331
+ catch (err) {
332
+ // Only genuine SSE transport / clean-stream-end failures fall through to
333
+ // poll. Caller-deadline, abort, and API errors (a 401/402/etc. from
334
+ // /events, or an onProgress callback throw surfacing as GislApiError)
335
+ // MUST propagate — re-issuing the same doomed request via poll would mask
336
+ // them. Mirrors the PHP BuilderInternals::awaitTerminal sealed-marker
337
+ // discipline (codex review medium).
338
+ if (err instanceof GislTimeoutError)
339
+ throw err;
340
+ if (err instanceof DOMException && err.name === 'AbortError')
341
+ throw err;
342
+ if (err instanceof GislApiError)
343
+ throw err;
344
+ finalStatus = await _pollToTerminal(this.client, {
345
+ workflowId: created.workflowId,
346
+ deadline,
347
+ signal,
348
+ pollIntervalMs: options.pollIntervalMs,
349
+ });
350
+ }
351
+ // 4. Fetch downloads. The maxWait deadline covers upload + create + wait +
352
+ // downloads, so check before issuing the request (mirrors builder.ts).
353
+ if (Date.now() >= deadline) {
354
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
355
+ }
356
+ const downloads = await this.client.getWorkflowDownloads(created.workflowId);
357
+ // Flatten to the lean OutputFile[] (the four file-first fields only).
358
+ const artifacts = [];
359
+ for (const job of downloads.downloads) {
360
+ for (const f of job.files) {
361
+ artifacts.push({
362
+ url: f.downloadUrl,
363
+ filename: f.filename,
364
+ sizeBytes: f.sizeBytes,
365
+ operation: f.operation,
366
+ });
367
+ }
368
+ }
369
+ // Partition the single input into succeeded / failed by terminal state.
370
+ // Success is ONLY `completed`: every other terminal state — `failed`,
371
+ // `partially_failed`, `cancelled`, `expired`,
372
+ // `paused_insufficient_credits` — is a non-success and partitions into
373
+ // `failed` so a caller's `ok`/`succeeded` check can never treat a
374
+ // cancelled/expired/paused run as a clean result (codex review high).
375
+ const state = finalStatus.status;
376
+ const key = this.recipeKey ?? null;
377
+ let succeeded;
378
+ let failed;
379
+ if (state === 'completed') {
380
+ succeeded = [{ key, outputs: artifacts }];
381
+ failed = [];
382
+ }
383
+ else {
384
+ const firstError = (finalStatus.jobs ?? [])
385
+ .flatMap((j) => j.operations ?? [])
386
+ .map((op) => op.errorMessage)
387
+ .find((m) => m !== undefined);
388
+ succeeded = [];
389
+ failed = [
390
+ { key, error: new Error(firstError !== undefined ? `${state}: ${firstError}` : state) },
391
+ ];
392
+ }
393
+ // Download URLs from getWorkflowDownloads are pre-signed and require no SDK
394
+ // auth, so the downloader issues a plain unauthenticated fetch.
395
+ const downloader = new HttpDownloader();
396
+ return new RunResult(created.workflowId, state, artifacts, succeeded, failed, downloader);
397
+ }
398
+ withStep(step) {
399
+ return new Recipe(this.input, this.recipeKey, [...this.steps, step], this.presetDefaults, this.scopedPresetDefaults, this.client);
400
+ }
401
+ lowerStep(step) {
402
+ const options = step.opType === 'compress' ? this.lowerCompressOptions(step.options) : { ...step.options };
403
+ // Empty options omit the `options` wire key entirely, so TS (undefined →
404
+ // absent) and PHP (null → absent) serialise byte-identically.
405
+ return Object.keys(options).length === 0
406
+ ? { type: step.opType }
407
+ : { type: step.opType, options };
408
+ }
409
+ lowerCompressOptions(options) {
410
+ const optimize = options.optimize;
411
+ const media = this.compressMediaHint();
412
+ if (media === undefined) {
413
+ // Cannot infer a media class (a Blob without a recognised name, or a
414
+ // bare upload id) → preset resolution is impossible. Fail FAST rather
415
+ // than silently dropping an explicit `optimize`; bare compress() is fine.
416
+ if (optimize !== undefined) {
417
+ throw new GislConfigError(`compress(optimize: ${String(optimize)}) needs a media type to resolve the preset, but the ` +
418
+ 'input has no inferable media (a pre-uploaded file id or unnamed Blob carries no extension). ' +
419
+ 'Use a path with a file extension, or call compress() without optimize.', { reason: 'media_unknown', conflictingFields: ['optimize'] });
420
+ }
421
+ return {};
422
+ }
423
+ const input = { media, op: 'compress', explicitOptions: {} };
424
+ if (this.presetDefaults !== undefined) {
425
+ input.presetDefaults = this.presetDefaults;
426
+ }
427
+ if (this.scopedPresetDefaults !== undefined) {
428
+ input.scopedPresetDefaults =
429
+ this.scopedPresetDefaults;
430
+ }
431
+ if (optimize !== undefined) {
432
+ input.optimize = optimize;
433
+ }
434
+ return { ...resolveCompressOptions(input).wireOptions };
435
+ }
436
+ compressMediaHint() {
437
+ if (this.input.kind === 'path') {
438
+ return _detectCompressMedia(this.input.path);
439
+ }
440
+ if (this.input.kind === 'blob') {
441
+ return _detectCompressMedia(this.input.blob);
442
+ }
443
+ return undefined;
444
+ }
445
+ }
@@ -1,4 +1,4 @@
1
- export type ErrorCode = "missing_credentials" | "feature_requires_auth" | "undeclared_asset" | "unused_asset" | "per_input_options_not_supported" | "chain_cardinality_mismatch" | "multipart_part_invalid" | "multipart_part_count_exceeded" | "timeout" | "aborted" | "validation_failed" | "auth_failed" | "feature_tier_restricted" | "tier_restriction" | "multipart_session_ownership" | "multipart_session_auth_required" | "multipart_session_not_found" | "workflow_expired" | "balance_exhausted" | "feature_not_available" | "upload_size_exceeds_tier" | "upload_duration_exceeds_tier" | "probe_pending" | "requires_reencode" | "invalid_options" | "invalid_combination" | "missing_dependency" | "unsupported_value" | "type_mismatch" | "upload_failed" | "workflow_failed";
1
+ export type ErrorCode = "missing_credentials" | "feature_requires_auth" | "undeclared_asset" | "unused_asset" | "per_input_options_not_supported" | "chain_cardinality_mismatch" | "multipart_part_invalid" | "multipart_part_count_exceeded" | "timeout" | "aborted" | "validation_failed" | "cyclic_workflow_edges" | "workflow_edge_references_unknown_job" | "reserved_job_id_pattern" | "cyclic_job_output_source_graph" | "auth_failed" | "feature_tier_restricted" | "tier_restriction" | "multipart_session_ownership" | "multipart_session_auth_required" | "multipart_session_not_found" | "workflow_expired" | "balance_exhausted" | "feature_not_available" | "upload_size_exceeds_tier" | "upload_duration_exceeds_tier" | "probe_pending" | "requires_reencode" | "invalid_options" | "invalid_combination" | "missing_dependency" | "unsupported_value" | "type_mismatch" | "upload_failed" | "workflow_failed";
2
2
  export type ErrorCategory = 'api' | 'config' | 'network' | 'auth' | 'validation' | 'chain';
3
3
  export type ErrorStatus = 'wired' | 'planned';
4
4
  export interface ErrorEntry {
@@ -150,6 +150,52 @@ export const ERROR_CODES = Object.freeze({
150
150
  "details": "array",
151
151
  }),
152
152
  }),
153
+ "cyclic_workflow_edges": Object.freeze({
154
+ code: "cyclic_workflow_edges",
155
+ category: "validation",
156
+ source: "ErrorEnvelope.error",
157
+ status: "wired",
158
+ httpStatus: 422,
159
+ retryable: false,
160
+ sdkClass: "GislValidationError",
161
+ description: "422 — cycle/self-edge in the explicit `workflow_edges` DAG. Wire `CYCLIC_WORKFLOW_EDGES` (g8PPkbNu); ValidationErrorEnvelope shape, `details[0].field`=`workflow_edges`.",
162
+ metadataSchema: Object.freeze({
163
+ "details": "array",
164
+ }),
165
+ }),
166
+ "workflow_edge_references_unknown_job": Object.freeze({
167
+ code: "workflow_edge_references_unknown_job",
168
+ category: "validation",
169
+ source: "ErrorEnvelope.error",
170
+ status: "wired",
171
+ httpStatus: 400,
172
+ retryable: false,
173
+ sdkClass: "GislValidationError",
174
+ description: "400 — a `workflow_edges` entry, job-level `source`, or `inputs[].source` references a job not in the request. Wire `WORKFLOW_EDGE_REFERENCES_UNKNOWN_JOB` (g8PPkbNu).",
175
+ metadataSchema: Object.freeze({}),
176
+ }),
177
+ "reserved_job_id_pattern": Object.freeze({
178
+ code: "reserved_job_id_pattern",
179
+ category: "validation",
180
+ source: "ErrorEnvelope.error",
181
+ status: "wired",
182
+ httpStatus: 400,
183
+ retryable: false,
184
+ sdkClass: "GislValidationError",
185
+ description: "400 — user job `id` matches the reserved `^job_\\d+$` pattern as the SOLE failure. Wire `RESERVED_JOB_ID_PATTERN` (g8PPkbNu); mixed violations keep generic `BAD_REQUEST`.",
186
+ metadataSchema: Object.freeze({}),
187
+ }),
188
+ "cyclic_job_output_source_graph": Object.freeze({
189
+ code: "cyclic_job_output_source_graph",
190
+ category: "validation",
191
+ source: "ErrorEnvelope.error",
192
+ status: "wired",
193
+ httpStatus: 400,
194
+ retryable: false,
195
+ sdkClass: "GislValidationError",
196
+ description: "400 — an implicit cycle in the `job_output` source graph, caught during effective-input-MIME resolution on POST /api/workflows. Wire `CYCLIC_JOB_OUTPUT_SOURCE_GRAPH` (g8PPkbNu).",
197
+ metadataSchema: Object.freeze({}),
198
+ }),
153
199
  "auth_failed": Object.freeze({
154
200
  code: "auth_failed",
155
201
  category: "auth",
@@ -458,6 +504,10 @@ export const ERROR_CATEGORIES = Object.freeze({
458
504
  "multipart_part_invalid",
459
505
  "multipart_part_count_exceeded",
460
506
  "validation_failed",
507
+ "cyclic_workflow_edges",
508
+ "workflow_edge_references_unknown_job",
509
+ "reserved_job_id_pattern",
510
+ "cyclic_job_output_source_graph",
461
511
  "invalid_options",
462
512
  "invalid_combination",
463
513
  "missing_dependency",
@@ -1,3 +1,3 @@
1
- export declare const SDK_SPEC_VERSION: "1.1.0";
1
+ export declare const SDK_SPEC_VERSION: "1.2.0";
2
2
  export declare const PRESET_VERSION: "1.0";
3
3
  export declare const PRESET_CONFIG_HASH: "sha256:35aeb0b6b86edd9814ace5b75cfeef8a7b1432ecb8e4d5ee8b38d9187a0b45eb";
@@ -1,6 +1,6 @@
1
1
  // CODE GENERATED — DO NOT EDIT.
2
2
  // Source: compression_contracts/sdk-spec/ (see sdk-spec/README.md).
3
3
  // Regenerate with: scripts/generate.py.
4
- export const SDK_SPEC_VERSION = "1.1.0";
4
+ export const SDK_SPEC_VERSION = "1.2.0";
5
5
  export const PRESET_VERSION = "1.0";
6
6
  export const PRESET_CONFIG_HASH = "sha256:35aeb0b6b86edd9814ace5b75cfeef8a7b1432ecb8e4d5ee8b38d9187a0b45eb";
package/dist/gisl.d.ts CHANGED
@@ -23,6 +23,7 @@ import type { GislClientConfig } from './types.js';
23
23
  import { OperationBuilder } from './builder.js';
24
24
  import { MergeBuilder, type Asset, type MergeOptions } from './merge.js';
25
25
  import { PresetDefaults } from './ergonomic/presets/index.js';
26
+ import { Recipe, type FileInput } from './file-first.js';
26
27
  /**
27
28
  * Operations that may be invoked on a `gisl.anonymous()` client without
28
29
  * raising `GislFeatureRequiresAuthError`. Empty until the free-tier launch
@@ -67,6 +68,15 @@ export declare function create(opts?: GislCreateOptions): Promise<ErgonomicClien
67
68
  * ergonomic factory's narrower string-only typing).
68
69
  */
69
70
  export type ErgonomicClient = GislClient & {
71
+ /**
72
+ * File-first entry point (FF2a). Returns an immutable {@link Recipe} you
73
+ * call operations on (`.compress()` / `.convert()` / `.thumbnail()` /
74
+ * `.textWatermark()`), chaining sequentially. A bare string is a filesystem
75
+ * path; pass a {@link FileInput} (e.g. `fileInput.uploadId(...)`) to reuse a
76
+ * pre-uploaded file. `key` is RESULT-addressing only — never input wiring.
77
+ * Execution (`run()`) lands in FF2b.
78
+ */
79
+ file(input: string | Blob | FileInput, key?: string): Recipe;
70
80
  compress(input: string | Blob, options?: Record<string, unknown>): OperationBuilder;
71
81
  convert(input: string | Blob, options?: Record<string, unknown>): OperationBuilder;
72
82
  thumbnail(input: string | Blob, options?: Record<string, unknown>): OperationBuilder;
package/dist/gisl.js CHANGED
@@ -22,6 +22,7 @@ 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
26
  // ---------------------------------------------------------------------------
26
27
  // Anonymous-capable operation allowlist (internal)
27
28
  // ---------------------------------------------------------------------------
@@ -71,6 +72,22 @@ export async function create(opts = {}) {
71
72
  function wrapErgonomic(client, presetDefaults, scopedPresetDefaults) {
72
73
  return new Proxy(client, {
73
74
  get(target, prop, receiver) {
75
+ if (prop === 'file') {
76
+ // File-first entry point — the subject of the file-first surface.
77
+ // A bare string is a filesystem path, a Blob/File an in-memory input;
78
+ // pass a `FileInput` (e.g. `fileInput.uploadId(...)`) to reuse a
79
+ // pre-uploaded file. `key` is RESULT-addressing only. The Proxy's
80
+ // closure forwards the same preset-defaults references the op builders
81
+ // get, so a file-first `compress()` resolves presets identically.
82
+ return (input, key) => {
83
+ const resolved = typeof input === 'string'
84
+ ? fileInput.path(input)
85
+ : input instanceof Blob
86
+ ? fileInput.blob(input)
87
+ : input;
88
+ return new Recipe(resolved, key, [], presetDefaults, scopedPresetDefaults, target);
89
+ };
90
+ }
74
91
  if (prop === 'compress' || prop === 'convert' || prop === 'thumbnail') {
75
92
  return (input, options = {}) => {
76
93
  // T4b — pass client-scope presetDefaults into the builder so
@@ -0,0 +1,9 @@
1
+ import type { Downloader } from './file-first.js';
2
+ /**
3
+ * Streams a (typically pre-signed) URL to a local path without buffering the
4
+ * whole body in memory. Pre-signed download URLs require no SDK auth, so this
5
+ * issues a plain unauthenticated `fetch`.
6
+ */
7
+ export declare class HttpDownloader implements Downloader {
8
+ downloadTo(url: string, destPath: string): Promise<void>;
9
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Node-only streaming {@link Downloader} implementation.
3
+ *
4
+ * Lives in its own module so the framework-free `file-first.ts` stays
5
+ * Node-import-free; the Node `fs`/`stream` imports are isolated here.
6
+ */
7
+ import { createWriteStream } from 'node:fs';
8
+ import { Readable } from 'node:stream';
9
+ import { pipeline } from 'node:stream/promises';
10
+ import { GislNetworkError, GislSinkError } from './errors.js';
11
+ /**
12
+ * Streams a (typically pre-signed) URL to a local path without buffering the
13
+ * whole body in memory. Pre-signed download URLs require no SDK auth, so this
14
+ * issues a plain unauthenticated `fetch`.
15
+ */
16
+ export class HttpDownloader {
17
+ async downloadTo(url, destPath) {
18
+ // Source-read failures surface as GislNetworkError to match the PHP
19
+ // StreamingDownloader (both raise GislNetworkError when the output URL
20
+ // cannot be fetched); a dest-write failure is GislSinkError(write_failed)
21
+ // on the RunResult sink side. Parity-critical: the FF1 sink contract tells
22
+ // callers to narrow with instanceof, so the source-read error type must
23
+ // match across languages.
24
+ let res;
25
+ try {
26
+ res = await fetch(url);
27
+ }
28
+ catch (cause) {
29
+ // A rejected fetch (DNS, TCP, TLS, mid-flight disconnect) must surface as
30
+ // GislNetworkError too — not the raw TypeError — so callers can narrow
31
+ // every download-source failure with `instanceof GislNetworkError`
32
+ // (codex review medium).
33
+ throw new GislNetworkError(`Failed to fetch download source: ${cause instanceof Error ? cause.message : String(cause)}`);
34
+ }
35
+ if (!res.ok) {
36
+ throw new GislNetworkError(`Download failed with status ${res.status}`);
37
+ }
38
+ if (res.body === null) {
39
+ throw new GislNetworkError('Download response had no body');
40
+ }
41
+ // `fetch`'s WHATWG ReadableStream and Node's `stream/web` ReadableStream
42
+ // are structurally the same at runtime but typed in two different lib
43
+ // declarations; the cast bridges them for `Readable.fromWeb`.
44
+ try {
45
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(destPath));
46
+ }
47
+ catch {
48
+ // Destination-write failures (unwritable dir, disk full, …) surface as
49
+ // GislSinkError(write_failed) to match the PHP StreamingDownloader — a
50
+ // raw Node ENOENT would otherwise leak through the parity-critical sink
51
+ // contract. Source-read failures are handled above as GislNetworkError.
52
+ throw new GislSinkError(`Failed to stream download to destination: ${destPath}`, { reason: 'write_failed' });
53
+ }
54
+ }
55
+ }
package/dist/index.d.ts CHANGED
@@ -4,8 +4,13 @@ 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, GislConfigError, GislMissingCredentialsError, GislFeatureRequiresAuthError, GislUndeclaredAssetError, GislUnusedAssetError, GislPerInputOptionsNotSupportedError, GislChainCardinalityMismatchError, } 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';
8
8
  export type { GislApiErrorOptions, GislUploadCapKind } from './errors.js';
9
+ export { RunResult } from './file-first.js';
10
+ export type { OutputFile, ItemResult, ItemFailure, Manifest, Downloader, } from './file-first.js';
11
+ export { Recipe, fileInput } from './file-first.js';
12
+ export type { FileInput } from './file-first.js';
13
+ export { HttpDownloader } from './http-downloader.js';
9
14
  export { gisl, create } from './gisl.js';
10
15
  export type { GislCreateOptions, Environment, ErgonomicClient } from './gisl.js';
11
16
  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';
package/dist/index.js CHANGED
@@ -7,6 +7,9 @@ export { uploadSource, jobOutputSource, externalImportSource, connectionSource,
7
7
  export { GislError, GislApiError, GislValidationError, GislBalanceExhaustedError, GislTierRestrictedError, GislFeatureTierRestrictedError, GislFeatureNotAvailableError, GislWorkflowExpiredError, GislProbePendingError, GislAuthError, GislUploadCapExceededError, GislMultipartPartError, GislMultipartPartCountError,
8
8
  // SDK-3 (Wb6ebOMM) — typed errors for the 3 resume-support endpoints.
9
9
  GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTimeoutError, GislAbortError,
10
+ // FF2b / tywwynmN — transport-level failure (mirrors PHP GislNetworkError);
11
+ // raised by the file-first HttpDownloader when an output URL cannot be read.
12
+ GislNetworkError,
10
13
  // T1 / wVU4xHx3 — local config-error tree (pre-I/O; sibling of GislApiError).
11
14
  GislConfigError, GislMissingCredentialsError, GislFeatureRequiresAuthError,
12
15
  // T3 / cuecCmb5 — merge-compose local validation errors.
@@ -14,7 +17,17 @@ GislUndeclaredAssetError, GislUnusedAssetError, GislPerInputOptionsNotSupportedE
14
17
  // T6 / aDR1jnyZ — chain-cardinality validation (dormant until chain
15
18
  // methods on OperationBuilder ship; type + audit registration land
16
19
  // here so the future chain-method PR is a pure addition).
17
- GislChainCardinalityMismatchError, } from './errors.js';
20
+ GislChainCardinalityMismatchError,
21
+ // FF1 / 3BIxEnfR — file-first result sink errors.
22
+ GislNoSuchKeyError, GislSinkError, } from './errors.js';
23
+ // File-first result surface (FF1 / 3BIxEnfR) — coexists with the
24
+ // operation-first `Result`/`Artifact` until FF6 removes the old layer.
25
+ export { RunResult } from './file-first.js';
26
+ // File-first builder (FF2a / MfV0PDok) — `client.file(path).op()...` lowering.
27
+ export { Recipe, fileInput } from './file-first.js';
28
+ // File-first execution (FF2b / MfV0PDok) — Node streaming downloader bound by
29
+ // `Recipe.run()` to write pre-signed output URLs to disk.
30
+ export { HttpDownloader } from './http-downloader.js';
18
31
  // Ergonomic-layer entrypoint (T1 / wVU4xHx3) — `gisl.create()` factory +
19
32
  // credential-chain types. `gisl.anonymous()` (public export) lands once
20
33
  // the anonymous-capable operation allowlist is non-empty (plan §12).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giveitsmaller/sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",
@@ -19,12 +19,12 @@
19
19
  "node": ">=18"
20
20
  },
21
21
  "dependencies": {
22
- "@giveitsmaller/contracts": "^0.8.0"
22
+ "@giveitsmaller/contracts": "^0.9.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^22",
26
26
  "typescript": "^5.7",
27
- "vitest": "^3.1",
27
+ "vitest": "^3.1 <3.2.5",
28
28
  "yaml": "^2.6"
29
29
  },
30
30
  "scripts": {