@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/_audit.js CHANGED
@@ -129,7 +129,12 @@ export function _runAudit() {
129
129
  accept();
130
130
  accept();
131
131
  accept();
132
+ // FF3a / u0hBt6fl — homogeneous fan-out builder surface.
132
133
  accept();
133
134
  accept();
134
135
  accept();
136
+ accept();
137
+ // FF5a / Ao8RPVxD — file-first Handle reattach surface.
138
+ accept();
139
+ accept();
135
140
  }
package/dist/builder.d.ts CHANGED
@@ -26,6 +26,7 @@
26
26
  */
27
27
  import type { GislClient } from './client.js';
28
28
  import type { OperationDownload, WorkflowStatusResponse, SseOperationProgressDataStatusEnum } from '@giveitsmaller/contracts/openapi';
29
+ import { Handle } from './handle.js';
29
30
  import type { PresetDefaults, PresetMedia } from './ergonomic/presets/index.js';
30
31
  /**
31
32
  * Best-effort detection of the compress-operation media from the
@@ -183,14 +184,6 @@ export interface Result {
183
184
  */
184
185
  readonly resolvedOptions: ResolvedOptions;
185
186
  }
186
- /**
187
- * Lighter return value from `.submit({webhook})` — no SSE/poll wait,
188
- * caller reconciles completion via the webhook.
189
- */
190
- export interface Handle {
191
- readonly workflowId: string;
192
- readonly webhookSecret?: string;
193
- }
194
187
  /**
195
188
  * Upload-phase progress event. The byte counter comes from
196
189
  * `UploadOptions.onProgress` — there is no `phase` field on the wire.
package/dist/builder.js CHANGED
@@ -27,6 +27,10 @@
27
27
  import { SseEventType, SseOperationProgressDataFromJSON, } from '@giveitsmaller/contracts/openapi';
28
28
  import { uploadSource } from './types.js';
29
29
  import { GislTimeoutError } from './errors.js';
30
+ // Deferred-usage-only import: `Handle` is constructed inside submit() at call
31
+ // time, not at module load, so the builder.ts <-> handle.ts cycle is safe
32
+ // under ESM (handle.ts imports the await-primitives from this module).
33
+ import { Handle } from './handle.js';
30
34
  import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
31
35
  /**
32
36
  * Best-effort detection of the compress-operation media from the
@@ -264,11 +268,9 @@ export class OperationBuilder {
264
268
  callback_url: options.webhook,
265
269
  };
266
270
  const created = await this.client.createWorkflow(payload);
267
- const handle = {
268
- workflowId: created.workflowId,
269
- ...(created.webhookSecret != null ? { webhookSecret: created.webhookSecret } : {}),
270
- };
271
- return handle;
271
+ // No client passed → the returned Handle's status()/wait()/result()
272
+ // throw `no_client`; the operation-first submit reconciles via webhook.
273
+ return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined);
272
274
  }
273
275
  /**
274
276
  * Fan-out chain: run this builder to completion, then for each artifact
package/dist/errors.d.ts CHANGED
@@ -348,6 +348,22 @@ export declare class GislMultipartPartCountError extends GislError {
348
348
  export declare class GislNoSuchKeyError extends GislError {
349
349
  constructor(message: string);
350
350
  }
351
+ /**
352
+ * Thrown by the file-first `Handle.result()` (FF5a) when the workflow has not
353
+ * yet reached a terminal state. `result()` is the NON-blocking accessor: it
354
+ * fetches the current status once and, if the workflow is still
355
+ * `pending`/`in_progress`, throws this rather than waiting. Use `Handle.wait()`
356
+ * to block until terminal instead.
357
+ *
358
+ * Carries the `workflowId` and the current (non-terminal) `state`.
359
+ *
360
+ * Mirrors the PHP `Gisl\Sdk\Errors\GislResultNotReadyError`.
361
+ */
362
+ export declare class GislResultNotReadyError extends GislError {
363
+ readonly workflowId: string;
364
+ readonly state: string;
365
+ constructor(workflowId: string, state: string);
366
+ }
351
367
  /** Machine-readable cause carried by {@link GislSinkError}. */
352
368
  export type GislSinkErrorReason = 'not_single_output' | 'downloader_unavailable' | 'partial_failure' | 'duplicate_filename' | 'invalid_directory' | 'write_failed';
353
369
  /**
package/dist/errors.js CHANGED
@@ -387,6 +387,28 @@ export class GislNoSuchKeyError extends GislError {
387
387
  this.name = 'GislNoSuchKeyError';
388
388
  }
389
389
  }
390
+ /**
391
+ * Thrown by the file-first `Handle.result()` (FF5a) when the workflow has not
392
+ * yet reached a terminal state. `result()` is the NON-blocking accessor: it
393
+ * fetches the current status once and, if the workflow is still
394
+ * `pending`/`in_progress`, throws this rather than waiting. Use `Handle.wait()`
395
+ * to block until terminal instead.
396
+ *
397
+ * Carries the `workflowId` and the current (non-terminal) `state`.
398
+ *
399
+ * Mirrors the PHP `Gisl\Sdk\Errors\GislResultNotReadyError`.
400
+ */
401
+ export class GislResultNotReadyError extends GislError {
402
+ workflowId;
403
+ state;
404
+ constructor(workflowId, state) {
405
+ super(`Workflow ${workflowId} is not ready (state '${state}'); its result is not available yet. ` +
406
+ 'Call wait() to block until it reaches a terminal state, or poll result() again later.');
407
+ this.name = 'GislResultNotReadyError';
408
+ this.workflowId = workflowId;
409
+ this.state = state;
410
+ }
411
+ }
390
412
  /**
391
413
  * Thrown by the file-first `RunResult` sinks (`toFile()` / `downloadTo()`,
392
414
  * FF1) when they cannot deliver. The machine-readable `reason` discriminates
@@ -11,9 +11,11 @@
11
11
  */
12
12
  import { type ProgressEvent } from './builder.js';
13
13
  import type { GislClient } from './client.js';
14
+ import type { OperationDownload, WorkflowStatusResponse } from '@giveitsmaller/contracts/openapi';
14
15
  import { OptimizeFor } from './generated/sdk_spec/enums.js';
15
16
  import type { PresetDefaults } from './ergonomic/presets/index.js';
16
17
  import type { WorkflowCreatePayload } from './types.js';
18
+ import { Handle } from './handle.js';
17
19
  /**
18
20
  * Streams a single output URL to a local path. The seam between the
19
21
  * file-first {@link RunResult} sinks and the SDK's HTTP/auth layer.
@@ -160,6 +162,68 @@ export declare class RunResult {
160
162
  };
161
163
  private requireDownloader;
162
164
  }
165
+ /**
166
+ * Flatten the terminal workflow status + its downloads into a {@link RunResult}.
167
+ *
168
+ * Shared by {@link Recipe.run} (passes its recipe key) and the file-first
169
+ * {@link Handle} reattach surface (`Handle.wait()`/`Handle.result()`, FF5a —
170
+ * passes `null` because a reattached handle carries no recipe key).
171
+ *
172
+ * **Partition invariant (carries a prior codex-review fix — do NOT let it
173
+ * drift):** success is ONLY `state === 'completed'`. Every other terminal
174
+ * state — `failed`, `partially_failed`, `cancelled`, `expired`,
175
+ * `paused_insufficient_credits` — partitions into `failed[]` so a caller's
176
+ * `ok`/`succeeded` check can never treat a cancelled/expired/paused run as a
177
+ * clean result.
178
+ *
179
+ * @internal Exported for reuse by the file-first `Handle`; not part of the
180
+ * caller-facing fluent surface.
181
+ */
182
+ export declare function projectDownloadsToRunResult(workflowId: string, finalStatus: WorkflowStatusResponse, jobDownloads: readonly {
183
+ files: readonly OperationDownload[];
184
+ }[], key: string | null, downloader?: Downloader): RunResult;
185
+ /**
186
+ * Flatten a terminal multi-job workflow (the `client.files([...])` fan-out)
187
+ * into a partitioned {@link RunResult}. One job per input file, keyed by the
188
+ * `file-{i}` job ref the {@link FilesRecipe} lowering assigns; the result's
189
+ * `succeeded` / `failed` partition is PER JOB, so one bad input does not sink
190
+ * the rest.
191
+ *
192
+ * Join model: `finalStatus.jobs[]` carries the per-job {@link JobStatus} +
193
+ * `operations[]` (for the error message); `jobDownloads[]` carries the per-job
194
+ * output files. Both are joined on the job `ref` ("file-{i}"); the partition
195
+ * key is the index `"{i}"` parsed out of that ref. The flat `artifacts[]` is
196
+ * every job's outputs in job order (the order `finalStatus.jobs[]` lists them).
197
+ *
198
+ * **Partition invariant (mirrors {@link projectDownloadsToRunResult} PER JOB —
199
+ * do NOT let it drift):** a job is a SUCCESS only when its
200
+ * {@link JobResponse.status} `=== 'completed'`. Any other per-job status —
201
+ * `failed`, `pending`, `waiting`, `blocked_insufficient_credits`,
202
+ * `in_progress` — partitions that job into `failed[]` (with that job's first
203
+ * operation error message, scoped to THAT job only).
204
+ *
205
+ * @internal Exported for the file-first `client.files([...]).run()` producer;
206
+ * not part of the caller-facing fluent surface.
207
+ */
208
+ export declare function projectMultiJobToRunResult(workflowId: string, finalStatus: WorkflowStatusResponse, jobDownloads: readonly {
209
+ ref: string;
210
+ files: readonly OperationDownload[];
211
+ }[], keyByRef: ReadonlyMap<string, string | null>, downloader?: Downloader): RunResult;
212
+ /**
213
+ * True when a terminal status describes a homogeneous `files([...])` fan-out —
214
+ * i.e. it has at least one job and EVERY job ref is `file-{i}` (the ids the
215
+ * {@link FilesRecipe} lowering assigns). A single-file {@link Recipe} omits the
216
+ * job id, so its job carries a non-`file-N` ref (e.g. `op`) and this is false.
217
+ *
218
+ * This is the data-driven seam that lets {@link Handle.wait}/{@link Handle.result}
219
+ * pick the per-job producer ({@link projectMultiJobToRunResult}) over the
220
+ * single-output one for a fan-out — WITHOUT a construction-time marker, so a
221
+ * fan-out **reattached** via `client.workflow(id)` (which carries no marker)
222
+ * still partitions per job. Keys are recovered from the `file-{i}` refs.
223
+ *
224
+ * @internal Exported for the file-first `Handle`; not part of the public API.
225
+ */
226
+ export declare function isFanoutStatus(finalStatus: WorkflowStatusResponse): boolean;
163
227
  /**
164
228
  * The primary file a {@link Recipe} operates on — the "subject" of the
165
229
  * file-first surface. A discriminated union over the ways a caller names an
@@ -250,15 +314,25 @@ export declare class Recipe {
250
314
  * `operations[]`; the job `id` is omitted (a single job referenced by
251
315
  * nothing — the server auto-assigns `job_N`).
252
316
  *
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.
317
+ * When `callbackUrl` is given (the file-first `submit()` path), it is built
318
+ * INTO the payload at construction (`callback_url`) rather than spread onto an
319
+ * already-built readonly payload. `run()` passes no `callbackUrl`.
320
+ *
321
+ * @internal Consumed by FF2b's `run()` (after a real upload), FF5b's
322
+ * `submit()` (with a webhook), and the cross-language parity harness (with a
323
+ * fixed id). Not part of the caller-facing fluent surface.
256
324
  */
257
- toWorkflowPayload(fileId: string): WorkflowCreatePayload;
325
+ toWorkflowPayload(fileId: string, callbackUrl?: string): WorkflowCreatePayload;
258
326
  /** The result-addressing key passed to `file()`, or undefined. */
259
327
  key(): string | undefined;
260
328
  /** The number of operations chained so far (introspection / tests). */
261
329
  get stepCount(): number;
330
+ /**
331
+ * The captured op chain. Read by {@link FilesRecipe} to compose a shared
332
+ * chain across many inputs without duplicating the chain-method validation.
333
+ * @internal
334
+ */
335
+ get recipeSteps(): readonly RecipeStep[];
262
336
  /**
263
337
  * Execute the recipe end-to-end: upload the input (when required), create
264
338
  * the workflow, await a terminal state (SSE with poll fallback), then
@@ -276,9 +350,154 @@ export declare class Recipe {
276
350
  signal?: AbortSignal;
277
351
  pollIntervalMs?: number;
278
352
  }): Promise<RunResult>;
353
+ /**
354
+ * Fire-and-forget the recipe: upload the input (when required), create the
355
+ * workflow (wiring `webhook` into `callback_url` when given), and return a
356
+ * client-bound {@link Handle} carrying the workflow id + webhook secret + the
357
+ * recipe key. Does NOT wait for terminal status — call `handle.wait()` /
358
+ * `handle.result()` later to collect the {@link RunResult}.
359
+ *
360
+ * Requires a client bound at construction time (same `no_client` guard as
361
+ * {@link run}). `webhook` is OPTIONAL: when omitted, no `callback_url` is
362
+ * sent. Mirrors the PHP `Recipe.submit()`.
363
+ *
364
+ * @param webhook Absolute callback URL the server POSTs lifecycle events to.
365
+ */
366
+ submit(webhook?: string): Promise<Handle>;
367
+ /**
368
+ * Resolve the upload id (verbatim for a pre-uploaded id; uploading a path /
369
+ * blob otherwise, emitting `{phase:'upload'}` progress), check the post-upload
370
+ * deadline, lower to the workflow-create payload (wiring `webhook` into
371
+ * `callback_url`), and create the workflow. Shared first half of
372
+ * {@link run} + {@link submit}.
373
+ *
374
+ * The post-upload deadline check carries a prior codex fix (9a117f04eb59): a
375
+ * slow upload must not proceed to createWorkflow past the deadline.
376
+ */
377
+ private _uploadAndCreate;
279
378
  private withStep;
280
379
  private lowerStep;
281
380
  private lowerCompressOptions;
282
381
  private compressMediaHint;
283
382
  }
383
+ /**
384
+ * The homogeneous fan-out builder value (FF3a). `client.files([a, b, c])`
385
+ * returns a `FilesRecipe`; the op-chain methods (`compress`, `convert`,
386
+ * `thumbnail`, `textWatermark`) build ONE shared recipe (chain) that is applied
387
+ * to EVERY input file in ONE workflow. `run()` returns a partitioned
388
+ * {@link RunResult} — one `succeeded`/`failed` entry per input, keyed by its
389
+ * 0-based index ("0", "1", …) so one bad input does not sink the rest.
390
+ *
391
+ * **Immutable / clone-on-write**, exactly like {@link Recipe}: every op returns
392
+ * a NEW `FilesRecipe` carrying the appended step. The inputs are held as an
393
+ * ORDERED list (NOT a map) so the per-file index is the partition key.
394
+ *
395
+ * **Lowering composes {@link Recipe} per file** rather than duplicating
396
+ * `lowerStep`/`lowerCompressOptions`: for each input `i` it builds an internal
397
+ * single-file `Recipe(input_i, …, steps)`, calls its `toWorkflowPayload` to get
398
+ * that file's one-job payload, then merges all jobs into ONE
399
+ * {@link WorkflowCreatePayload} with `jobs[i].id = "file-{i}"`. This preserves
400
+ * each file's media-hint (different extensions per input resolve compress
401
+ * presets independently).
402
+ *
403
+ * Exposes both `run()` (blocking, returns a partitioned {@link RunResult}) and
404
+ * `submit(webhook?)` (fire-and-forget, returns a {@link Handle}). Mirrors the
405
+ * PHP `FilesRecipe`.
406
+ */
407
+ export declare class FilesRecipe {
408
+ private readonly inputs;
409
+ private readonly steps;
410
+ private readonly presetDefaults?;
411
+ private readonly scopedPresetDefaults?;
412
+ private readonly client?;
413
+ constructor(inputs: readonly FileInput[], steps?: readonly RecipeStep[], presetDefaults?: PresetDefaults | undefined, scopedPresetDefaults?: PresetDefaults | undefined, client?: GislClient | undefined);
414
+ /**
415
+ * Reduce file size on every input. `optimize` selects a per-media preset
416
+ * (resolved per file at lower-time, so each input's extension picks its own
417
+ * preset). Reuses {@link Recipe}'s validation — a directly-constructed
418
+ * lowering builds an internal Recipe that throws the same `GislConfigError`.
419
+ */
420
+ compress(optimize?: OptimizeFor): FilesRecipe;
421
+ /** Change every input's format. `format` lowers verbatim to the `format` option. */
422
+ convert(format: string): FilesRecipe;
423
+ /** Generate a preview of every input. Omitted dimensions are dropped from the wire options. */
424
+ thumbnail(options?: {
425
+ width?: number;
426
+ height?: number;
427
+ }): FilesRecipe;
428
+ /** Apply the same text watermark to every input. */
429
+ textWatermark(text: string): FilesRecipe;
430
+ /** The number of inputs in this fan-out (introspection / tests). */
431
+ get inputCount(): number;
432
+ /** The number of operations chained so far (introspection / tests). */
433
+ get stepCount(): number;
434
+ /**
435
+ * Lower this fan-out to a single multi-job workflow-create payload against a
436
+ * list of resolved upload ids (one per input, in input order). Each input `i`
437
+ * becomes ONE job with `id = "file-{i}"`, its `source: upload(fileIds[i])`,
438
+ * and the SHARED lowered `operations[]`. Composes the single-file
439
+ * {@link Recipe.toWorkflowPayload} per file so per-file media-hints resolve
440
+ * independently and lowering logic is not duplicated.
441
+ *
442
+ * @internal Consumed by {@link run} (after uploading all inputs) and the
443
+ * cross-language parity harness (with fixed ids). Not caller-facing.
444
+ */
445
+ toWorkflowPayload(fileIds: readonly string[], callbackUrl?: string): WorkflowCreatePayload;
446
+ /**
447
+ * Execute the fan-out end-to-end: upload EVERY input, create ONE workflow
448
+ * with one job per input, await a terminal state (SSE with poll fallback),
449
+ * then resolve the per-job downloads into a partitioned {@link RunResult}.
450
+ * `partially_failed` is a NORMAL terminal state here — its successful jobs
451
+ * land in `succeeded`, its failed jobs in `failed`.
452
+ *
453
+ * Requires a client bound at construction time — `gisl().files(...)` wires
454
+ * it; a directly-constructed `FilesRecipe` throws {@link GislConfigError}.
455
+ * Mirrors the single-file {@link Recipe.run}; see {@link submit} for the
456
+ * fire-and-forget arm.
457
+ */
458
+ run(options?: {
459
+ maxWait?: string | number;
460
+ onProgress?: (event: ProgressEvent) => void;
461
+ signal?: AbortSignal;
462
+ pollIntervalMs?: number;
463
+ }): Promise<RunResult>;
464
+ /**
465
+ * Fire-and-forget the fan-out: upload every input, create ONE multi-job
466
+ * workflow (wiring `webhook` into `callback_url` when given), and return a
467
+ * client-bound {@link Handle}. Does NOT wait for terminal status — call
468
+ * `handle.wait()` / `handle.result()` later to collect the partitioned
469
+ * {@link RunResult}. The Handle detects the fan-out from the wire `file-{i}`
470
+ * job refs, so per-file `byKey()` works even after a `client.workflow(id)`
471
+ * reattach (the keys are the input indices `"0"`, `"1"`, …).
472
+ *
473
+ * Requires a client bound at construction time (same `no_client` guard as
474
+ * {@link run}). `webhook` is OPTIONAL. Fire-and-forget, so NO whole-run
475
+ * deadline (a multi-GB upload is bounded by the HTTP client's own timeout).
476
+ * Mirrors the single-file {@link Recipe.submit}.
477
+ *
478
+ * @param webhook Absolute callback URL the server POSTs lifecycle events to.
479
+ */
480
+ submit(webhook?: string): Promise<Handle>;
481
+ /**
482
+ * Upload every input (verbatim for a pre-uploaded id; uploading a path /
483
+ * blob otherwise, emitting `{phase:'upload'}` progress) then create ONE
484
+ * multi-job workflow (one job per input, `callback_url` built in when
485
+ * `webhook` is given). Shared first half of {@link run} + {@link submit}.
486
+ *
487
+ * Uploads are sequential so progress events stay ordered and the abort
488
+ * signal is honoured promptly; a resource arm is impossible in TS (Blob).
489
+ * `run()` passes a whole-run deadline (a slow upload must not proceed to
490
+ * createWorkflow past maxWait); `submit()` passes `undefined`, so the
491
+ * deadline checks are skipped.
492
+ */
493
+ private _uploadAllAndCreate;
494
+ /**
495
+ * The shared single-file {@link Recipe} that captures the op chain (input is
496
+ * a placeholder — only the steps are read). Reuses Recipe's op-chain
497
+ * validation + coercion so a `FilesRecipe.compress(bad)` throws the identical
498
+ * `GislConfigError` as `Recipe.compress(bad)`.
499
+ */
500
+ private baseRecipe;
501
+ private withStep;
502
+ }
284
503
  export {};