@giveitsmaller/sdk 0.26.0 → 0.28.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
@@ -75,12 +75,90 @@ export function _runAudit() {
75
75
  // methods EXIST and their signatures/return types match (indexed access errors
76
76
  // if a method is missing; the typed LHS errors if the signature drifts). The
77
77
  // RHS is a type-only cast (`null as unknown as …`) — no runtime property read.
78
- const _creditsSig = null;
79
- const _creditsUsageSig = null;
80
- const _limitsSig = null;
78
+ //
79
+ // 🔴 THE ASSIGNMENT FORM BELOW WAS WEAKER THAN IT LOOKED, AND IS NOW FIXED.
80
+ // A single assignment tests ONE-WAY assignability, so a drift to `any` — or to any
81
+ // broader callable — stays assignable and PASSES. Assigning both ways does not fix
82
+ // it either: `any` is assignable in both directions. Real equality needs a
83
+ // conditional-type helper, and this repo already had one in
84
+ // `ergonomic/option_types.ts`, used there to pin option key-sets to the contract.
85
+ // ⇒ These three have been decorative since 8yqUXLCS shipped them. Tightened here
86
+ // rather than left one-way beside sixteen correct ones.
87
+ const _creditsSig = true;
88
+ const _creditsUsageSig = true;
89
+ const _limitsSig = true;
81
90
  void _creditsSig;
82
91
  void _creditsUsageSig;
83
92
  void _limitsSig;
93
+ // ── BQXpFV2R — the thirteen unpinned ergonomic symbols ────────────────────────
94
+ //
95
+ // ⚠️ EXISTENCE IS NOW THE SNAPSHOT'S JOB, not this list's.
96
+ // `tests/api-surface.test.ts` computes all 381 exports from source and compares
97
+ // them to a committed file, so a symbol cannot go unpinned because nobody
98
+ // remembered it — which is exactly how these thirteen were missed, alongside the
99
+ // whole `Gisl*Error` tree and `GislClient` itself. What remains here is the part a
100
+ // name-and-kind snapshot CANNOT express: SIGNATURES.
101
+ //
102
+ // ⚠️ COVERAGE BOUNDARY, stated so the gate is not mistaken for complete:
103
+ // signatures are pinned for the nine builders' execution methods and the three
104
+ // ErgonomicClient accessors above. A signature change to `GislClient`, `gisl`,
105
+ // `create` or `parseSseStream` is caught by NOTHING here. That is a deliberate
106
+ // scope line — widening it is ticket `YebCTMuY`.
107
+ accept();
108
+ accept();
109
+ accept();
110
+ accept();
111
+ accept();
112
+ accept();
113
+ accept();
114
+ accept();
115
+ accept();
116
+ accept();
117
+ accept();
118
+ accept();
119
+ accept();
120
+ accept();
121
+ accept();
122
+ accept();
123
+ accept();
124
+ accept();
125
+ accept();
126
+ const _recipeRun = true;
127
+ const _recipeSubmit = true;
128
+ const _filesRun = true;
129
+ const _filesSubmit = true;
130
+ const _mergedRun = true;
131
+ const _mergedSubmit = true;
132
+ const _archivedRun = true;
133
+ const _archivedSubmit = true;
134
+ const _watermarkedRun = true;
135
+ const _watermarkedSubmit = true;
136
+ const _batchRun = true;
137
+ const _opRun = true;
138
+ const _opSubmit = true;
139
+ const _mergeRun = true;
140
+ const _mergeSubmit = true;
141
+ // ⚠️ `Promise<Result>`, NOT `Promise<Result[]>`. A fan-out returns ONE aggregate
142
+ // result carrying `childWorkflowIds`, not an array. I wrote `Result[]` from
143
+ // assumption and this pin failed on its first compile — the gate catching a wrong
144
+ // belief before any mutation test, which is the whole point of writing it out.
145
+ const _mapEachRun = true;
146
+ void _recipeRun;
147
+ void _recipeSubmit;
148
+ void _filesRun;
149
+ void _filesSubmit;
150
+ void _mergedRun;
151
+ void _mergedSubmit;
152
+ void _archivedRun;
153
+ void _archivedSubmit;
154
+ void _watermarkedRun;
155
+ void _watermarkedSubmit;
156
+ void _batchRun;
157
+ void _opRun;
158
+ void _opSubmit;
159
+ void _mergeRun;
160
+ void _mergeSubmit;
161
+ void _mapEachRun;
84
162
  accept();
85
163
  accept();
86
164
  accept();
package/dist/builder.d.ts CHANGED
@@ -20,9 +20,11 @@
20
20
  * `SseOperationProgressData`. The `phase` discriminator is SDK-added;
21
21
  * `status` values pass through verbatim from `SseOperationProgressDataStatusEnum`.
22
22
  * The wire does NOT carry a `phase` field — see karen reality-check 2026-05-23.
23
- * - `.run()` requires `maxWait` (no default). The underlying `waitForWorkflow`
24
- * has a 600s default for the poll fallback path; the ergonomic layer makes
25
- * it MANDATORY in the type so callers consciously choose a deadline.
23
+ * - `.run()` takes an OPTIONAL options bag; `maxWait` defaults to
24
+ * `DEFAULT_POLL_TIMEOUT_MS`, the same constant the poll fallback and every
25
+ * file-first builder use. It was mandatory until 36AZ98FV, on the grounds
26
+ * that inheriting that default would "leak silently" — while the SDK applied
27
+ * it at fourteen sites regardless.
26
28
  */
27
29
  import type { GislClient } from './client.js';
28
30
  import type { OperationDownload, WorkflowStatusResponse, SseOperationProgressDataStatusEnum } from '@giveitsmaller/contracts/openapi';
@@ -235,12 +237,17 @@ export type ProgressEvent = UploadProgressEvent | ProcessingProgressEvent;
235
237
  export interface RunOptions {
236
238
  /**
237
239
  * Wall-clock deadline for the entire run (upload + create + wait + downloads).
238
- * MANDATORY the SDK does NOT supply a default because the underlying
239
- * `waitForWorkflow` poll path has a 600s default that would otherwise
240
- * leak silently. Pass `'2h'` / `'30m'` / `'120s'` as a string suffix or
241
- * a number of milliseconds.
240
+ * Optional; defaults to {@link DEFAULT_POLL_TIMEOUT_MS}. Pass `'2h'` / `'30m'` /
241
+ * `'120s'` as a string suffix or a number of milliseconds.
242
+ *
243
+ * ⚠️ THIS WAS MANDATORY, on the stated grounds that a 600s default "would
244
+ * otherwise leak silently" (36AZ98FV). The same tree applied exactly that
245
+ * default at FOURTEEN sites across both SDKs, so the prohibition was refuted
246
+ * by the code it protected — and the file-first spelling of the same task
247
+ * accepted no arguments at all. One shared constant is what makes the rule
248
+ * unnecessary rather than what breaks it.
242
249
  */
243
- readonly maxWait: string | number;
250
+ readonly maxWait?: string | number;
244
251
  /** Abort signal — terminates upload, SSE, and poll cleanly. */
245
252
  readonly signal?: AbortSignal;
246
253
  /** Progress callback receiving the SDK-synthesised discriminated union. */
@@ -265,8 +272,18 @@ export interface RunOptions {
265
272
  readonly probeTimeoutMs?: number;
266
273
  }
267
274
  export interface SubmitOptions {
268
- /** Webhook URL — wired to `WorkflowCreateRequest.callback_url`. */
269
- readonly webhook: string;
275
+ /**
276
+ * Webhook URL — wired to `WorkflowCreateRequest.callback_url`.
277
+ *
278
+ * Optional. `callback_url` is not in `WorkflowCreateRequest`'s required set and
279
+ * is typed `string | null`, so omitting it is contract-valid. It was mandatory
280
+ * here because the returned {@link Handle} carried no client and its
281
+ * `status()`/`wait()`/`result()` threw `no_client` — the webhook was the only
282
+ * channel by which the outcome could be learned. The handle is bound now
283
+ * (36AZ98FV), so omitting the webhook leaves a usable handle rather than a
284
+ * dead end.
285
+ */
286
+ readonly webhook?: string;
270
287
  /**
271
288
  * Best-effort probe-before-create for a VIDEO upload that went multipart.
272
289
  * Default `true`; set `false` to skip the wait. See {@link RunOptions}.
@@ -341,14 +358,14 @@ export declare class OperationBuilder {
341
358
  * fetches downloads, and projects to a flat `Result`. Throws
342
359
  * `GislTimeoutError` if `maxWait` elapses before terminal status.
343
360
  */
344
- run(options: RunOptions): Promise<Result>;
361
+ run(options?: RunOptions): Promise<Result>;
345
362
  /**
346
363
  * Fire-and-forget: upload the input + create the workflow with a
347
364
  * `callback_url` wired to the supplied `webhook`, then return a
348
365
  * `Handle` (workflowId + webhookSecret) without waiting. The webhook
349
366
  * receives completion + the `webhookSecret` is the verifier seed.
350
367
  */
351
- submit(options: SubmitOptions): Promise<Handle>;
368
+ submit(options?: SubmitOptions): Promise<Handle>;
352
369
  /**
353
370
  * Fan-out chain: run this builder to completion, then for each artifact
354
371
  * in the resulting `Result`, call `fn(artifactRef)` to construct a
@@ -391,8 +408,25 @@ export declare class MapEachBuilder {
391
408
  * run + every child's full run — each child sees the REMAINING budget
392
409
  * after the parent and prior children completed. Signal aborts cascade.
393
410
  */
394
- run(options: RunOptions): Promise<Result>;
411
+ run(options?: RunOptions): Promise<Result>;
395
412
  }
413
+ /**
414
+ * The clamp itself, exported for an EXACT test (codex d218bd6a0c62).
415
+ *
416
+ * ⚠️ **A behavioural test cannot pin this number, and that is why this seam
417
+ * exists.** Counting requests over a real deadline discriminates 1000 ms from
418
+ * 100 and from 500, but it cannot tell 1000 from 750 — the counts collide inside
419
+ * scheduler jitter. Widening the window to separate them makes the suite slower
420
+ * and the test flakier, in exchange for a weaker claim.
421
+ *
422
+ * ⇒ So the two tests do different jobs and neither is redundant: this one pins
423
+ * the VALUE exactly, and the `run()` test proves the clamp is on the path a
424
+ * caller actually travels. A value test alone would pass while nothing called
425
+ * it; a path test alone would pass at 750 ms.
426
+ *
427
+ * @internal — not re-exported from the package barrel.
428
+ */
429
+ export declare function _clampPollIntervalMs(requested: number | undefined): number;
396
430
  /** @internal — exported for reuse by `merge.ts` (T3) and future builders. */
397
431
  export declare function _consumeSseToTerminal(client: GislClient, args: {
398
432
  workflowId: string;
package/dist/builder.js CHANGED
@@ -20,10 +20,13 @@
20
20
  * `SseOperationProgressData`. The `phase` discriminator is SDK-added;
21
21
  * `status` values pass through verbatim from `SseOperationProgressDataStatusEnum`.
22
22
  * The wire does NOT carry a `phase` field — see karen reality-check 2026-05-23.
23
- * - `.run()` requires `maxWait` (no default). The underlying `waitForWorkflow`
24
- * has a 600s default for the poll fallback path; the ergonomic layer makes
25
- * it MANDATORY in the type so callers consciously choose a deadline.
23
+ * - `.run()` takes an OPTIONAL options bag; `maxWait` defaults to
24
+ * `DEFAULT_POLL_TIMEOUT_MS`, the same constant the poll fallback and every
25
+ * file-first builder use. It was mandatory until 36AZ98FV, on the grounds
26
+ * that inheriting that default would "leak silently" — while the SDK applied
27
+ * it at fourteen sites regardless.
26
28
  */
29
+ import { DEFAULT_POLL_TIMEOUT_MS } from './client.js';
27
30
  import { SseEventType, SseOperationProgressDataFromJSON, } from '@giveitsmaller/contracts/openapi';
28
31
  import { uploadSource } from './types.js';
29
32
  import { GislTimeoutError, GislFanOutTimeoutError, GislNetworkError, GislStreamHostNotDeclaredError, GislTransportError, SseEndedWithoutTerminal } from './errors.js';
@@ -253,8 +256,8 @@ export class OperationBuilder {
253
256
  * fetches downloads, and projects to a flat `Result`. Throws
254
257
  * `GislTimeoutError` if `maxWait` elapses before terminal status.
255
258
  */
256
- async run(options) {
257
- const deadline = Date.now() + _parseMaxWait(options.maxWait);
259
+ async run(options = {}) {
260
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? DEFAULT_POLL_TIMEOUT_MS);
258
261
  const signal = options.signal;
259
262
  const onProgress = options.onProgress;
260
263
  const useSSE = options.useSSE ?? true;
@@ -333,7 +336,7 @@ export class OperationBuilder {
333
336
  * `Handle` (workflowId + webhookSecret) without waiting. The webhook
334
337
  * receives completion + the `webhookSecret` is the verifier seed.
335
338
  */
336
- async submit(options) {
339
+ async submit(options = {}) {
337
340
  // Resolve presets before any I/O so a GislConfigError fails the
338
341
  // call before the upload — same fail-early contract as run().
339
342
  const resolved = this._resolve();
@@ -350,14 +353,22 @@ export class OperationBuilder {
350
353
  source: uploadSource(uploadResp.fileId),
351
354
  operations: [{ type: this.opType, options: resolved.wireOptions }],
352
355
  };
356
+ // ⚠️ OMIT THE KEY, do not set it to `undefined`. `JSON.stringify` drops an
357
+ // undefined value so the wire is the same either way — but an own property
358
+ // that exists with no value makes `'callback_url' in payload` TRUE, so any
359
+ // test asserting omission by key passes vacuously (codex 4763eb48189a).
353
360
  const payload = {
354
361
  jobs: [job],
355
- callback_url: options.webhook,
362
+ ...(options.webhook !== undefined ? { callback_url: options.webhook } : {}),
356
363
  };
357
364
  const created = await this.client.createWorkflow(payload);
358
- // No client passed the returned Handle's status()/wait()/result()
359
- // throw `no_client`; the operation-first submit reconciles via webhook.
360
- return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined);
365
+ // ⚠️ THE CLIENT IS THE POINT (36AZ98FV). Without it the returned Handle's
366
+ // status()/wait()/result() throw `no_client`, which made `webhook` the only
367
+ // channel for this call's outcome and is why it used to be mandatory. The
368
+ // file-first path has always passed it (`file-first.ts`); the operation-first
369
+ // path did not, which is the same "demands what file-first does not" defect
370
+ // this ticket is about, one layer down.
371
+ return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, null);
361
372
  }
362
373
  /**
363
374
  * Fan-out chain: run this builder to completion, then for each artifact
@@ -438,8 +449,8 @@ export class MapEachBuilder {
438
449
  * run + every child's full run — each child sees the REMAINING budget
439
450
  * after the parent and prior children completed. Signal aborts cascade.
440
451
  */
441
- async run(options) {
442
- const deadline = Date.now() + _parseMaxWait(options.maxWait);
452
+ async run(options = {}) {
453
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? DEFAULT_POLL_TIMEOUT_MS);
443
454
  // 1. Run the parent.
444
455
  const remainingForParent = Math.max(1, deadline - Date.now());
445
456
  const parentResult = await this.parent.run({
@@ -540,6 +551,82 @@ function aggregateWorkflowStatus(statuses) {
540
551
  // ---------------------------------------------------------------------------
541
552
  // Internals — SSE + poll
542
553
  // ---------------------------------------------------------------------------
554
+ /**
555
+ * Poll-fallback interval bounds, in milliseconds.
556
+ *
557
+ * 🔴 **THE FLOOR IS SIZED AGAINST A PUBLISHED RATE LIMIT, NOT AGAINST A
558
+ * BUSY-LOOP.** Its predecessor was 100 ms and its comment said it guarded
559
+ * against values "that would hammer getWorkflowStatus" — which is what you
560
+ * write when you are stopping a `0`/`NaN` spin, not when you have asked what
561
+ * the server allows. Same words, different standard, and nothing recorded
562
+ * which one applied (`r7bpd7MY`).
563
+ *
564
+ * The limit, read from `compression_api` rather than relayed:
565
+ * `Identity/Application/RateLimiting/TieredRateLimiterService.php:37-39`
566
+ * declares the `status_poll` family — guarding `GET /api/workflows/{id}/status`
567
+ * and `/downloads` — as a **sliding window of 60 requests per minute**, scaled
568
+ * at consume time (`:177`, `:188`) by `UserTier::rateLimitMultiplier()`
569
+ * (`Identity/Domain/Enums/UserTier.php:186-194`): Free and Basic ×1, Pro ×5,
570
+ * Max ×15, Enterprise ×20.
571
+ *
572
+ * ⇒ 100 ms is **600 requests/minute**: ten times the Free ceiling, twice Pro's,
573
+ * and inside budget only on Max and Enterprise. 1000 ms is the minimum legal
574
+ * interval on the tightest tier, so it is correct on every tier and needs no
575
+ * tier knowledge in the SDK.
576
+ *
577
+ * ⚠️ **THE WINDOW IS SLIDING, SO IT PUNISHES BURSTS, NOT JUST AVERAGES** — ten
578
+ * polls in the first second genuinely consume ten of the sixty.
579
+ *
580
+ * 📌 **AND THE BUDGET IS SHARED**: keyed per user id when authenticated, per IP
581
+ * when not. N SDK instances under one account spend one allowance between them,
582
+ * which is the argument for the conservative floor even on a paid tier — this
583
+ * process cannot see the other two.
584
+ *
585
+ * A tier-aware floor would need the caller's tier, which neither SDK reads
586
+ * today, and api has carded the effective-limits field as `4cHoxAcm` (Backlog,
587
+ * unscheduled — deliberately, because this flat floor ships without it).
588
+ *
589
+ * ⛔ **WHAT THIS DOES NOT DO, STATED SO NOBODY READS IT AS A GUARANTEE (codex
590
+ * 6b56d75f126d).** This is a PER-CALL floor, not a per-credential budget:
591
+ *
592
+ * - **Two concurrent `run()`s under one credential issue ~120 requests/minute**
593
+ * and blow a 60/minute bucket. Bounding that needs coordination the SDK does
594
+ * not have — a shared limiter across calls, processes and machines.
595
+ * - **`getWorkflowDownloads` draws on the SAME `status_poll` family**, so a run
596
+ * that spends the last token on a status poll can be 429'd on the terminal
597
+ * downloads call it needs to finish.
598
+ *
599
+ * ⇒ This change makes a SINGLE run legal on every tier. It does not make the SDK
600
+ * rate-limit-safe under concurrency, and the honest next step is retry-on-429
601
+ * with `Retry-After`, not a larger number here. Tracked separately.
602
+ */
603
+ const MIN_POLL_INTERVAL_MS = 1_000;
604
+ const DEFAULT_POLL_INTERVAL_MS = 2_000;
605
+ /**
606
+ * The clamp itself, exported for an EXACT test (codex d218bd6a0c62).
607
+ *
608
+ * ⚠️ **A behavioural test cannot pin this number, and that is why this seam
609
+ * exists.** Counting requests over a real deadline discriminates 1000 ms from
610
+ * 100 and from 500, but it cannot tell 1000 from 750 — the counts collide inside
611
+ * scheduler jitter. Widening the window to separate them makes the suite slower
612
+ * and the test flakier, in exchange for a weaker claim.
613
+ *
614
+ * ⇒ So the two tests do different jobs and neither is redundant: this one pins
615
+ * the VALUE exactly, and the `run()` test proves the clamp is on the path a
616
+ * caller actually travels. A value test alone would pass while nothing called
617
+ * it; a path test alone would pass at 750 ms.
618
+ *
619
+ * @internal — not re-exported from the package barrel.
620
+ */
621
+ export function _clampPollIntervalMs(requested) {
622
+ if (requested === undefined) {
623
+ return DEFAULT_POLL_INTERVAL_MS;
624
+ }
625
+ if (!Number.isFinite(requested) || requested < MIN_POLL_INTERVAL_MS) {
626
+ return MIN_POLL_INTERVAL_MS;
627
+ }
628
+ return requested;
629
+ }
543
630
  const TERMINAL_STATUS = new Set([
544
631
  'completed',
545
632
  'failed',
@@ -705,20 +792,11 @@ export async function _consumeSseToTerminal(client, args) {
705
792
  export async function _pollToTerminal(client, args) {
706
793
  // Codex r1 medium 89130e3ea75d — guard against 0/negative/NaN/Infinity
707
794
  // pollIntervalMs values that would hammer getWorkflowStatus until maxWait.
708
- const requested = args.pollIntervalMs;
709
- let intervalMs;
710
- if (requested === undefined) {
711
- intervalMs = 2_000;
712
- }
713
- else if (!Number.isFinite(requested) || requested < 100) {
714
- // Clamp to a safe minimum (100ms) rather than throw — small/zero/NaN
715
- // were almost certainly a caller mistake, but ergonomic-layer
716
- // shouldn't crash an otherwise valid run on this.
717
- intervalMs = 100;
718
- }
719
- else {
720
- intervalMs = requested;
721
- }
795
+ // Clamp rather than throw — a zero/NaN/tiny value is a caller mistake, and the
796
+ // ergonomic layer should not crash an otherwise valid run over it. ONE call
797
+ // site, so the exact test above and the behavioural test below are talking
798
+ // about the same code.
799
+ const intervalMs = _clampPollIntervalMs(args.pollIntervalMs);
722
800
  while (true) {
723
801
  _checkAborted(args.signal);
724
802
  if (Date.now() >= args.deadline) {
package/dist/client.d.ts CHANGED
@@ -2,6 +2,19 @@ import type { AudioWatermarkDecodeRequest, AudioWatermarkDecodeResponse, Externa
2
2
  import type { CreditsUsageOptions, ListWorkflowsOptions, GetSchemaOptions, GetSchemaResult, GislClientConfig, GislSseEvent, GislSseParseFailure, PreflightClipsResult, ProbeWaitOptions, ProbeWaitResult, ReadCapabilityOptions, UploadOptions, WaitOptions, WorkflowCreatePayload, _Sdk3HandCodedKeepaliveResult, _Sdk3HandCodedMultipartStatusResult, _Sdk3HandCodedPresignPartsResult } from './types.js';
3
3
  export declare const MULTIPART_CONCURRENCY_DEFAULT: 4;
4
4
  export declare const DEFAULT_MULTIPART_FIRST_CHUNK_SIZE: number;
5
+ /**
6
+ * THE ONE DEADLINE DEFAULT (36AZ98FV). One wall-clock deadline covers the whole
7
+ * operation — upload, create, wait, downloads. Every entry point that takes a
8
+ * `maxWait` defaults to THIS value; a caller who passes one overrides it, and a
9
+ * caller who does not gets the same deadline whichever spelling they used.
10
+ *
11
+ * ⚠️ EXPORTED FOR REUSE, NOT FOR CONSUMERS. It is deliberately absent from the
12
+ * package entry points, so it does not reach the public API surface and the
13
+ * committed export snapshots do not move. Before this ticket the number was
14
+ * already named here and in PHP's `WorkflowConstants`, and hard-coded at
15
+ * FOURTEEN defaulting sites anyway — which is the duplication this replaces.
16
+ */
17
+ export declare const DEFAULT_POLL_TIMEOUT_MS = 600000;
5
18
  export interface ValidationDetail {
6
19
  message: string;
7
20
  field?: string;
package/dist/client.js CHANGED
@@ -63,7 +63,19 @@ const S3_MAX_MULTIPART_PARTS = 10_000;
63
63
  // exists to prevent). codex review (high).
64
64
  const RECOMMENDED_CHUNK_SIZE_MAX_BYTES = 104_857_600; // 100 MiB
65
65
  const DEFAULT_POLL_INTERVAL_MS = 2_000;
66
- const DEFAULT_POLL_TIMEOUT_MS = 600_000; // 10 min
66
+ /**
67
+ * THE ONE DEADLINE DEFAULT (36AZ98FV). One wall-clock deadline covers the whole
68
+ * operation — upload, create, wait, downloads. Every entry point that takes a
69
+ * `maxWait` defaults to THIS value; a caller who passes one overrides it, and a
70
+ * caller who does not gets the same deadline whichever spelling they used.
71
+ *
72
+ * ⚠️ EXPORTED FOR REUSE, NOT FOR CONSUMERS. It is deliberately absent from the
73
+ * package entry points, so it does not reach the public API surface and the
74
+ * committed export snapshots do not move. Before this ticket the number was
75
+ * already named here and in PHP's `WorkflowConstants`, and hard-coded at
76
+ * FOURTEEN defaulting sites anyway — which is the duplication this replaces.
77
+ */
78
+ export const DEFAULT_POLL_TIMEOUT_MS = 600_000; // 10 min
67
79
  /**
68
80
  * Re-tag a bare per-request transport {@link GislTimeoutError} with the workflow
69
81
  * it was scoped to, so a timed-out workflow read (status / downloads) stays
@@ -21,10 +21,10 @@ export declare const GISL_STREAM_BASE_URL_ENV = "GISL_STREAM_BASE_URL";
21
21
  * Named environments → base URLs. Kept colocated with the resolver so the
22
22
  * mapping table doesn't leak into `gisl.ts`.
23
23
  */
24
- export declare const ENVIRONMENT_ENDPOINTS: {
24
+ export declare const ENVIRONMENT_ENDPOINTS: Readonly<{
25
25
  readonly prod: "https://api.giveitsmaller.com";
26
26
  readonly staging: "https://api.staging.giveitsmaller.com";
27
- };
27
+ }>;
28
28
  export type Environment = keyof typeof ENVIRONMENT_ENDPOINTS;
29
29
  export declare const DEFAULT_ENDPOINT: "https://api.giveitsmaller.com";
30
30
  /**
@@ -61,8 +61,15 @@ export declare const DEFAULT_ENDPOINT: "https://api.giveitsmaller.com";
61
61
  * `localhost` is intentionally absent too: it is declared in the contract as a
62
62
  * development server, but there is no `localhost` *environment* name to key it
63
63
  * off. Local callers pass `{streamBaseUrl}` or set `GISL_STREAM_BASE_URL`.
64
+ * ⚠️ **FROZEN, AND PART OF THE PUBLIC SURFACE.** Both tables are exported from the
65
+ * package barrel because the SDK requires callers on the low-level surface to
66
+ * supply `streamBaseUrl` and previously published no way to learn the declared
67
+ * hosts. They are the SAME objects this module's resolvers read, so a consumer
68
+ * mutating one would have repointed the SDK's own resolution — hence
69
+ * `Object.freeze`, which makes that a no-op in sloppy mode and a `TypeError`
70
+ * under `'use strict'` (every ES module) rather than a silent redirection.
64
71
  */
65
- export declare const ENVIRONMENT_STREAM_ENDPOINTS: Partial<Record<Environment, string>>;
72
+ export declare const ENVIRONMENT_STREAM_ENDPOINTS: Readonly<Partial<Record<Environment, string>>>;
66
73
  export interface ResolveCredentialsOptions {
67
74
  /** Explicit API key — highest precedence. */
68
75
  readonly apiKey?: string;
@@ -25,10 +25,10 @@ export const GISL_STREAM_BASE_URL_ENV = 'GISL_STREAM_BASE_URL';
25
25
  * Named environments → base URLs. Kept colocated with the resolver so the
26
26
  * mapping table doesn't leak into `gisl.ts`.
27
27
  */
28
- export const ENVIRONMENT_ENDPOINTS = {
28
+ export const ENVIRONMENT_ENDPOINTS = Object.freeze({
29
29
  prod: 'https://api.giveitsmaller.com',
30
30
  staging: 'https://api.staging.giveitsmaller.com',
31
- };
31
+ });
32
32
  export const DEFAULT_ENDPOINT = ENVIRONMENT_ENDPOINTS.prod;
33
33
  /**
34
34
  * Named environments → **SSE stream host**. A SECOND host, deliberately
@@ -64,11 +64,18 @@ export const DEFAULT_ENDPOINT = ENVIRONMENT_ENDPOINTS.prod;
64
64
  * `localhost` is intentionally absent too: it is declared in the contract as a
65
65
  * development server, but there is no `localhost` *environment* name to key it
66
66
  * off. Local callers pass `{streamBaseUrl}` or set `GISL_STREAM_BASE_URL`.
67
+ * ⚠️ **FROZEN, AND PART OF THE PUBLIC SURFACE.** Both tables are exported from the
68
+ * package barrel because the SDK requires callers on the low-level surface to
69
+ * supply `streamBaseUrl` and previously published no way to learn the declared
70
+ * hosts. They are the SAME objects this module's resolvers read, so a consumer
71
+ * mutating one would have repointed the SDK's own resolution — hence
72
+ * `Object.freeze`, which makes that a no-op in sloppy mode and a `TypeError`
73
+ * under `'use strict'` (every ES module) rather than a silent redirection.
67
74
  */
68
- export const ENVIRONMENT_STREAM_ENDPOINTS = {
75
+ export const ENVIRONMENT_STREAM_ENDPOINTS = Object.freeze({
69
76
  prod: 'https://stream.giveitsmaller.com',
70
77
  staging: 'https://stream.staging.giveitsmaller.com',
71
- };
78
+ });
72
79
  // ---------------------------------------------------------------------------
73
80
  // Public resolvers
74
81
  // ---------------------------------------------------------------------------
@@ -241,7 +248,29 @@ function readEnv(name) {
241
248
  return null;
242
249
  }
243
250
  const value = process.env[name];
244
- return typeof value === 'string' && value.length > 0 ? value : null;
251
+ // 🔴 A WHITESPACE-ONLY ENV VAR IS UNSET, and the presence check is what says
252
+ // so — the value itself is returned UNTOUCHED.
253
+ //
254
+ // `resolveStreamEndpoint` already trims the `streamBaseUrl` OPTION before its
255
+ // presence check (codex a7f5ec9f0d32) and this path did not, so the two
256
+ // disagreed about the same question: `GISL_STREAM_BASE_URL=' '` was returned
257
+ // verbatim as the stream host — a URL made of spaces — while the equivalent
258
+ // option resolved to the environment's declared host. The same asymmetry let a
259
+ // blank `GISL_BASE_URL` count as "the API host was configured" and suppress
260
+ // the production stream fallback.
261
+ //
262
+ // ⚠️ Presence only. A non-blank value is NOT trimmed here: silently altering a
263
+ // value the caller supplied is how you end up connecting somewhere they did
264
+ // not name. Blank means unset; anything else means what it says.
265
+ //
266
+ // Reported by compression_e2e, 2026-09-15: a whitespace-only value is TRUTHY
267
+ // in JavaScript, so a consumer's `if (url)` guard passes and the SDK then
268
+ // fails downstream — the mismatch only exists because the two sides answer
269
+ // "is this set?" differently.
270
+ if (typeof value !== 'string' || value.trim() === '') {
271
+ return null;
272
+ }
273
+ return value;
245
274
  }
246
275
  function defaultProfilePath() {
247
276
  const homeDir = readEnv('HOME') ?? readEnv('USERPROFILE');
@@ -215,6 +215,7 @@ export interface OutputOptions {
215
215
  /** JPEG/WebP lossless. Honored: same_format jpeg/webp (stable since v2.101.0). */
216
216
  lossless?: boolean;
217
217
  }
218
+ export type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
218
219
  /**
219
220
  * The user-supplyable option keys per verb (excludes positional-owned keys).
220
221
  * Exported for the wire-key conformance guard, which asserts each tuple ∪ its
@@ -492,8 +492,12 @@ export declare class Recipe {
492
492
  * Composite an image OVERLAY onto this file (a multi-input op). `overlay` is a
493
493
  * secondary file-NODE (a {@link Recipe} — e.g. `client.file('logo.png')`),
494
494
  * itself optionally processed first. Routes by THIS file's effective media:
495
- * image base → `image_watermark` (stable), video base → `video_watermark`
496
- * (beta). Audio/document/animated-GIF/unsupported-subtype/undetectable bases
495
+ * image base → `image_watermark` (stable).
496
+ * A VIDEO base is REFUSED: `video_watermark` was withdrawn to `planned`
497
+ * by contracts v2.203.0, so this verb throws before any upload rather than
498
+ * building a workflow the server would reject. The routing is unchanged and
499
+ * returns when the operation is re-listed.
500
+ * Audio/document/animated-GIF/unsupported-subtype/undetectable bases
497
501
  * throw locally BEFORE any upload (the planned-op gate). `options` carries the
498
502
  * wire watermark options (`anchor`, `opacity`, `margin_x`, `margin_y`,
499
503
  * `overlay_width`, or `overlays[]` for the multi-overlay stack). Returns a
@@ -671,7 +675,7 @@ export declare const WATERMARK_CAPABILITY: {
671
675
  readonly video_watermark: {
672
676
  readonly video: {
673
677
  readonly mimes: readonly ["video/mp4", "video/webm"];
674
- readonly availability: "stable";
678
+ readonly availability: "planned";
675
679
  };
676
680
  };
677
681
  };
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { GislConfigError, GislItemFailedError, GislNetworkError, GislNoSuchKeyError, GislSinkError, GislStreamHostNotDeclaredError, GislTimeoutError, SseEndedWithoutTerminal } from './errors.js';
13
13
  import { _detectCompressMedia, _detectAudioLossless, _consumeSseToTerminal, _pollToTerminal, _parseMaxWait, _checkAborted, _cappedProbeTimeoutMs, } from './builder.js';
14
+ import { DEFAULT_POLL_TIMEOUT_MS } from './client.js';
14
15
  import { LazyHttpDownloader } from './lazy-downloader.js';
15
16
  import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
16
17
  import { validateVerbOptions, assertThumbnailDimensions } from './ergonomic/option_validation.js';
@@ -821,8 +822,12 @@ export class Recipe {
821
822
  * Composite an image OVERLAY onto this file (a multi-input op). `overlay` is a
822
823
  * secondary file-NODE (a {@link Recipe} — e.g. `client.file('logo.png')`),
823
824
  * itself optionally processed first. Routes by THIS file's effective media:
824
- * image base → `image_watermark` (stable), video base → `video_watermark`
825
- * (beta). Audio/document/animated-GIF/unsupported-subtype/undetectable bases
825
+ * image base → `image_watermark` (stable).
826
+ * A VIDEO base is REFUSED: `video_watermark` was withdrawn to `planned`
827
+ * by contracts v2.203.0, so this verb throws before any upload rather than
828
+ * building a workflow the server would reject. The routing is unchanged and
829
+ * returns when the operation is re-listed.
830
+ * Audio/document/animated-GIF/unsupported-subtype/undetectable bases
826
831
  * throw locally BEFORE any upload (the planned-op gate). `options` carries the
827
832
  * wire watermark options (`anchor`, `opacity`, `margin_x`, `margin_y`,
828
833
  * `overlay_width`, or `overlays[]` for the multi-overlay stack). Returns a
@@ -920,7 +925,7 @@ export class Recipe {
920
925
  if (this.client === undefined) {
921
926
  throw new GislConfigError('Recipe.run() requires a client; build the recipe via gisl().file(...) rather than constructing Recipe directly.', { reason: 'no_client' });
922
927
  }
923
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
928
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? DEFAULT_POLL_TIMEOUT_MS);
924
929
  // 1+2. Upload (when required) + create the workflow. Shared with submit()
925
930
  // (which passes a webhook → callback_url). run() passes no webhook.
926
931
  const created = await this._uploadAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
@@ -1372,7 +1377,14 @@ export const WATERMARK_CAPABILITY = {
1372
1377
  image_bmp: { mimes: ['image/bmp'], availability: 'stable' },
1373
1378
  },
1374
1379
  video_watermark: {
1375
- video: { mimes: ['video/mp4', 'video/webm'], availability: 'stable' },
1380
+ // 🔴 WITHDRAWN to `planned` by contracts v2.203.0 — withdrawn 2026-09-15, tag cut 2026-09-16 —
1381
+ // after three measured staging proofs. It was `stable` — a worker EXISTS,
1382
+ // and it cannot serve the ceiling this contract advertised. That is a
1383
+ // different kind of `planned` from `image_gif` below, where nothing is built
1384
+ // yet, and the two have opposite remedies; no contract field separates them
1385
+ // (contracts SYQhXb6R). `_WATERMARK_SHIPPABLE` treats both as not-shippable,
1386
+ // which is right for the gate and worth knowing when rendering a reason.
1387
+ video: { mimes: ['video/mp4', 'video/webm'], availability: 'planned' },
1376
1388
  },
1377
1389
  };
1378
1390
  const _WATERMARK_SHIPPABLE = new Set(['stable', 'beta']);
@@ -1472,8 +1484,19 @@ function _resolveWatermarkWireOp(base) {
1472
1484
  if (group.mimes.includes(mime)) {
1473
1485
  if (_WATERMARK_SHIPPABLE.has(group.availability))
1474
1486
  return wireOp;
1475
- throw new GislConfigError(`watermark for ${mime} bases is not yet available (${wireOp} is '${group.availability}'). ` +
1476
- 'The contract schema is defined but the server returns feature_not_available until it ships.', { reason: 'feature_not_available' });
1487
+ // ⚠️ "NOT YET" WAS A PROMISE THE CONTRACT DOES NOT MAKE. This message
1488
+ // used to say the schema "is defined but the server returns
1489
+ // feature_not_available until it ships" — true for a capability nobody
1490
+ // has built, and FALSE for one that was `stable` last week and has been
1491
+ // WITHDRAWN (contracts v2.203.0 did exactly that to `video_watermark`).
1492
+ // Telling a user to wait for something that shipped and was pulled is
1493
+ // worse than telling them nothing. No contract field separates the two
1494
+ // kinds yet (contracts SYQhXb6R), so the wording states only what is
1495
+ // true of both.
1496
+ throw new GislConfigError(`watermark for ${mime} bases is not available (${wireOp} is ` +
1497
+ `'${group.availability}' in the contract this SDK was built against). ` +
1498
+ 'Workflow-create would return feature_not_available, so this is refused ' +
1499
+ 'before any upload. Check getSchema() for the current server answer.', { reason: 'feature_not_available' });
1477
1500
  }
1478
1501
  }
1479
1502
  }
@@ -1754,7 +1777,7 @@ export class FilesRecipe {
1754
1777
  if (this.client === undefined) {
1755
1778
  throw new GislConfigError('FilesRecipe.run() requires a client; build the fan-out via gisl().files(...) rather than constructing FilesRecipe directly.', { reason: 'no_client' });
1756
1779
  }
1757
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
1780
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? DEFAULT_POLL_TIMEOUT_MS);
1758
1781
  // 1+2. Upload EVERY input + create ONE multi-job workflow. Shared with
1759
1782
  // submit() (which passes a webhook → callback_url and no deadline).
1760
1783
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
@@ -1983,7 +2006,7 @@ export class MergedRecipe {
1983
2006
  if (this.client === undefined) {
1984
2007
  throw new GislConfigError('MergedRecipe.run() requires a client; build the merge via gisl().files(...).merge(...) rather than constructing MergedRecipe directly.', { reason: 'no_client' });
1985
2008
  }
1986
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
2009
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? DEFAULT_POLL_TIMEOUT_MS);
1987
2010
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
1988
2011
  const finalStatus = await _awaitTerminal(this.client, {
1989
2012
  workflowId: created.workflowId,
@@ -2196,7 +2219,7 @@ export class ArchivedRecipe {
2196
2219
  if (this.client === undefined) {
2197
2220
  throw new GislConfigError('ArchivedRecipe.run() requires a client; build the bundle via gisl().files(...).archive(...) rather than constructing ArchivedRecipe directly.', { reason: 'no_client' });
2198
2221
  }
2199
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
2222
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? DEFAULT_POLL_TIMEOUT_MS);
2200
2223
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
2201
2224
  const finalStatus = await _awaitTerminal(this.client, {
2202
2225
  workflowId: created.workflowId,
@@ -2409,7 +2432,7 @@ export class WatermarkedRecipe {
2409
2432
  if (this.client === undefined) {
2410
2433
  throw new GislConfigError('WatermarkedRecipe.run() requires a client; build the watermark via gisl().file(...).watermark(...) rather than constructing WatermarkedRecipe directly.', { reason: 'no_client' });
2411
2434
  }
2412
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
2435
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? DEFAULT_POLL_TIMEOUT_MS);
2413
2436
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
2414
2437
  const finalStatus = await _awaitTerminal(this.client, {
2415
2438
  workflowId: created.workflowId,
@@ -2597,7 +2620,7 @@ export class BatchRecipe {
2597
2620
  // FilesRecipe behavior and is intentionally NOT closed here — a TS
2598
2621
  // path-precheck would diverge batch from FilesRecipe.)
2599
2622
  this.validatePreUpload();
2600
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
2623
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? DEFAULT_POLL_TIMEOUT_MS);
2601
2624
  // 1+2. Upload each entry's input + create ONE multi-job workflow. batch v1
2602
2625
  // sends NO webhook (run()-only), so `callback_url` is omitted from the
2603
2626
  // payload (the closure receives `callbackUrl` undefined).
package/dist/handle.js CHANGED
@@ -32,6 +32,7 @@
32
32
  *
33
33
  * Mirrors the PHP `Gisl\Sdk\Ergonomic\Handle` + `Gisl\Sdk\Ergonomic\StatusSnapshot`.
34
34
  */
35
+ import { DEFAULT_POLL_TIMEOUT_MS } from './client.js';
35
36
  import { GislConfigError, GislNetworkError, GislResultNotReadyError, GislTimeoutError, GislStreamHostNotDeclaredError, SseEndedWithoutTerminal, } from './errors.js';
36
37
  import { _consumeSseToTerminal, _pollToTerminal, _parseMaxWait, } from './builder.js';
37
38
  import { projectDownloadsToRunResult, projectMultiJobToRunResult, isFanoutStatus, isMergeStatus, isArchiveStatus, isWatermarkStatus, isSoleOpChainStatus, soleOpChainDeliverableRef, _POST_STEP_JOB_REF, } from './file-first.js';
@@ -154,7 +155,7 @@ export class Handle {
154
155
  * @throws {GislConfigError} reason `no_client` when no client is bound.
155
156
  * @throws {GislTimeoutError} when `maxWait` elapses before terminal.
156
157
  */
157
- async wait(maxWait = 600_000, onProgress) {
158
+ async wait(maxWait = DEFAULT_POLL_TIMEOUT_MS, onProgress) {
158
159
  const client = this.requireClient();
159
160
  const deadline = Date.now() + _parseMaxWait(maxWait);
160
161
  let finalStatus;
@@ -19,6 +19,7 @@ export type { WatermarkWireOp } from './file-first.js';
19
19
  export { BatchRecipe } from './file-first.js';
20
20
  export { Handle, StatusSnapshot } from './handle.js';
21
21
  export { gisl, create } from './gisl.js';
22
+ export { ENVIRONMENT_ENDPOINTS, ENVIRONMENT_STREAM_ENDPOINTS } from './credentials.js';
22
23
  export type { GislCreateOptions, Environment, ErgonomicClient, SingleInputOperationType, MultiInputOperationType, } from './gisl.js';
23
24
  export { presetDefaults, PresetDefaults, type PresetMedia, type PresetOp, type AnyPresetOptions, ImageCompressPresetOptions, type ImageCompressPresetOptionsInput, AudioCompressPresetOptions, type AudioCompressPresetOptionsInput, VideoCompressPresetOptions, type VideoCompressPresetOptionsInput, DocumentOfficeCompressPresetOptions, type DocumentOfficeCompressPresetOptionsInput, DocumentOdfCompressPresetOptions, type DocumentOdfCompressPresetOptionsInput, DocumentEpubCompressPresetOptions, type DocumentEpubCompressPresetOptionsInput, OptimizeFor, ImageMetadataPolicy, ImageFormat, VideoCodec, VideoPreset, VideoFit, AudioBitrate, AudioCodec, AudioSampleRate, } from './ergonomic/presets/index.js';
24
25
  export { OperationBuilder, MapEachBuilder } from './builder.js';
@@ -90,6 +90,31 @@ export { Handle, StatusSnapshot } from './handle.js';
90
90
  // credential-chain types. `gisl.anonymous()` (public export) lands once
91
91
  // the anonymous-capable operation allowlist is non-empty (plan §12).
92
92
  export { gisl, create } from './gisl.js';
93
+ // 🔑 THE TWO ENDPOINT TABLES, EXPORTED BECAUSE THE SDK DEMANDS A VALUE IT DID
94
+ // NOT PUBLISH (e2e, 2026-09-15).
95
+ //
96
+ // `streamEvents()` fails CLOSED: `resolveStreamEndpoint` returns `null` rather
97
+ // than deriving a stream host from `baseUrl`, because deriving one is what put
98
+ // production on the gateway path. That design is right and is not changing.
99
+ //
100
+ // But its consequence is that a low-level `new GislClient({…})` caller MUST
101
+ // supply `streamBaseUrl` — and until now the table of declared hosts was
102
+ // reachable by no import a consumer could write: the `exports` map admits only
103
+ // `.` and `./browser`, and neither barrel re-exported these. ⇒ Every such
104
+ // consumer hard-codes the hosts. A library that fails closed on a value and
105
+ // does not export the value converts good design into N private copies that
106
+ // drift, and the copies are invisible until one is wrong.
107
+ //
108
+ // ⚠️ A SET OF HOSTS IS NOT ENOUGH, WHICH IS WHY THIS EXPORTS THE OBJECTS
109
+ // RATHER THAN A LIST. e2e's interim guard read our shipped `dist` as text and
110
+ // compared host sets in both directions — it could not have caught staging and
111
+ // prod being SWAPPED, which is the exact mistake that sends a credentialed
112
+ // stream request to the wrong environment. The PAIRING is the thing worth
113
+ // exporting.
114
+ //
115
+ // Frozen at their definition: these are the same objects the resolver reads, so
116
+ // a consumer mutating one would have repointed the SDK's own resolution.
117
+ export { ENVIRONMENT_ENDPOINTS, ENVIRONMENT_STREAM_ENDPOINTS } from './credentials.js';
93
118
  // Ergonomic preset defaults (T4a / VhIj4S7T) — typed leaf DTOs + immutable
94
119
  // `PresetDefaults` builder + `presetDefaults()` factory + ergonomic enum
95
120
  // re-exports. Resolver wiring (T4b) consumes `PresetDefaults.cellFor()`.
package/dist/merge.d.ts CHANGED
@@ -168,8 +168,8 @@ export declare class MergeBuilder {
168
168
  * and deduped on upload (one upload per unique declared asset).
169
169
  */
170
170
  sequence(...entries: SequenceEntry[]): this;
171
- run(options: RunOptions): Promise<Result>;
172
- submit(options: SubmitOptions): Promise<Handle>;
171
+ run(options?: RunOptions): Promise<Result>;
172
+ submit(options?: SubmitOptions): Promise<Handle>;
173
173
  /**
174
174
  * Resolve the declared assets + sequence (or fall back to declared order),
175
175
  * dedupe by identity, and run the local validators. The returned plan
package/dist/merge.js CHANGED
@@ -25,6 +25,7 @@
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 { DEFAULT_POLL_TIMEOUT_MS } from './client.js';
28
29
  import { uploadSource, jobOutputSource } from './types.js';
29
30
  import { GislConfigError, GislNetworkError, GislPerInputOptionsNotSupportedError, GislTimeoutError, GislUndeclaredAssetError, GislUnusedAssetError, GislStreamHostNotDeclaredError, SseEndedWithoutTerminal, } from './errors.js';
30
31
  import { _cappedProbeTimeoutMs, _checkAborted, _consumeSseToTerminal, _detectCompressMedia, _parseMaxWait, _pollToTerminal, _projectResult, } from './builder.js';
@@ -82,8 +83,8 @@ export class MergeBuilder {
82
83
  this.sequenceEntries = entries;
83
84
  return this;
84
85
  }
85
- async run(options) {
86
- const deadline = Date.now() + _parseMaxWait(options.maxWait);
86
+ async run(options = {}) {
87
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? DEFAULT_POLL_TIMEOUT_MS);
87
88
  const signal = options.signal;
88
89
  const onProgress = options.onProgress;
89
90
  const useSSE = options.useSSE ?? true;
@@ -146,7 +147,7 @@ export class MergeBuilder {
146
147
  const mergeDownloads = downloads.downloads.filter((d) => d.ref === 'merge');
147
148
  return _projectResult(finalStatus, mergeDownloads, this.opOptionsForResolved(plan.mediaKind));
148
149
  }
149
- async submit(options) {
150
+ async submit(options = {}) {
150
151
  const plan = this.planSequence();
151
152
  const probeTargets = [];
152
153
  const uploadedByAssetId = await this.uploadUniqueAssets(plan.uniqueAssets, { probeTargets });
@@ -154,11 +155,17 @@ export class MergeBuilder {
154
155
  // Fire-and-forget — no deadline, so no cap (mirrors Recipe::submit()).
155
156
  await this.waitForVideoProbes(probeTargets, options.probeBeforeCreate, options.probeTimeoutMs, undefined, undefined);
156
157
  const payload = this.buildPayload(plan, uploadedByAssetId);
157
- payload.callback_url = options.webhook;
158
+ // Set only when present — an own `callback_url: undefined` would make a
159
+ // key-presence assertion pass vacuously. See builder.ts.
160
+ if (options.webhook !== undefined)
161
+ payload.callback_url = options.webhook;
158
162
  const created = await this.client.createWorkflow(payload);
159
- // No client passed the returned Handle's status()/wait()/result()
160
- // throw `no_client`; the merge submit reconciles via webhook.
161
- return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined);
163
+ // ⚠️ The client is passed so the returned Handle is usable (36AZ98FV). This
164
+ // comment previously read "No client passed the returned Handle's
165
+ // status()/wait()/result() throw `no_client`; the merge submit reconciles via
166
+ // webhook" — an accurate description of a defect. `webhook` is optional now,
167
+ // so an unbound handle here would leave a submit with no outcome channel at all.
168
+ return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, null);
162
169
  }
163
170
  // ---------------------------------------------------------------------------
164
171
  /**
package/dist/types.d.ts CHANGED
@@ -361,7 +361,7 @@ export interface ReadCapabilityOptions {
361
361
  export interface WaitOptions {
362
362
  /** Poll interval in milliseconds (default: 2000) */
363
363
  intervalMs?: number;
364
- /** Maximum wait time in milliseconds (default: 600000 = 10 min) */
364
+ /** Maximum wait time in milliseconds (default: `DEFAULT_POLL_TIMEOUT_MS`, 10 min). */
365
365
  timeoutMs?: number;
366
366
  /** Called after each poll with current status */
367
367
  onPoll?: (status: string) => void;
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@giveitsmaller/sdk",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
+ "gislContractsSpec": "v2.203.0",
4
5
  "description": "Node.js SDK for the GISL (Give It Smaller) file compression and processing API",
5
6
  "license": "Apache-2.0",
6
7
  "type": "module",
@@ -31,7 +32,7 @@
31
32
  "node": ">=18"
32
33
  },
33
34
  "dependencies": {
34
- "@giveitsmaller/contracts": "^0.70.0"
35
+ "@giveitsmaller/contracts": "^0.72.0"
35
36
  },
36
37
  "devDependencies": {
37
38
  "@types/node": "^22",
@@ -46,7 +47,8 @@
46
47
  "check": "tsc --noEmit",
47
48
  "test": "vitest run",
48
49
  "test:parity": "vitest run tests/parity",
49
- "parity:update": "UPDATE_PARITY_FIXTURES=1 vitest run tests/parity"
50
+ "parity:update": "UPDATE_PARITY_FIXTURES=1 vitest run tests/parity",
51
+ "api:update": "UPDATE_API_SNAPSHOT=1 vitest run tests/api-surface.test.ts"
50
52
  },
51
53
  "homepage": "https://docs.giveitsmaller.com",
52
54
  "repository": {