@giveitsmaller/sdk 0.21.0 → 0.22.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/README.md CHANGED
@@ -56,6 +56,54 @@ for (const artifact of many.artifacts) console.log(artifact.url);
56
56
  > only under the same auth that created it; the upload-then-create flow above is
57
57
  > consistent by construction. Anonymous-intake uploads are unaffected.
58
58
 
59
+ ## Known limitation — browser SSE is restricted to one origin
60
+
61
+ **Applies to the browser build only** (`@giveitsmaller/sdk/browser`), and **only to live progress
62
+ streaming** — `streamEvents()`, and the `run({ useSSE: true })` default that uses it.
63
+
64
+ Live progress is served from a **separate host** to the rest of the API, and that host allows
65
+ **exactly one browser origin**: the Give It Smaller web app. So from a browser on any other origin:
66
+
67
+ | what you are doing | works? |
68
+ |---|---|
69
+ | Uploads, workflow create, status, downloads | **Yes** — these stay on the main API host, which allows a **list** of origins (per environment) |
70
+ | `streamEvents()` / SSE progress | **No** — blocked at the CORS preflight |
71
+
72
+ **Affected consumers:** third-party sites embedding the SDK, embedded/iframe use, and **local
73
+ development against staging** — which is the one most likely to bite first, because it looks like a
74
+ bug in your code.
75
+
76
+ ⚠️ **Local dev against staging is the sharp edge, and the reason is worth stating:** the two hosts
77
+ have *different* CORS policies, for a platform reason rather than an oversight. The main API host
78
+ allows a **list** of origins — and the **staging** list includes the usual localhost dev ports — so a
79
+ browser on `localhost` works against staging today. The stream host allows exactly **one** origin,
80
+ because it is a different API product with no native CORS configuration and nowhere to put a second
81
+ value. So everything keeps working right up until live progress, and then fails with a CORS error —
82
+ which reads like a mistake in your own application rather than a platform limitation.
83
+
84
+ **Production allows only the production web app on both hosts**, and always has.
85
+
86
+ **Workaround:** pass `useSSE: false` to `run()`. The SDK falls back to polling, which goes to the
87
+ main API host and is unaffected. Everything else about the call is identical.
88
+
89
+ **Why it cannot simply be widened:** the stream is cookie-credentialed, and the CORS specification
90
+ forbids combining `Access-Control-Allow-Credentials: true` with `Access-Control-Allow-Origin: *`.
91
+ The header also accepts exactly one origin — a comma-separated list is not valid. Supporting more
92
+ origins requires the server to validate and echo the request's `Origin`, which is planned but not
93
+ shipped.
94
+
95
+ **On authentication:** prefer an API key (`bearerAuth`) or the anonymous capability token on the
96
+ stream host. Cookie/session auth is accepted by the endpoint but a *credentialed cross-origin*
97
+ request additionally needs the browser to opt in and the server to answer with matching credential
98
+ headers — cookie domain scope alone is not sufficient, and this path is not verified.
99
+
100
+ **Node consumers are unaffected** — CORS is a browser mechanism. The PHP SDK is unaffected for the
101
+ same reason.
102
+
103
+ > ⚠️ This limitation is invisible to automated testing: our own app's origin is allowed, so every
104
+ > test and canary we run passes while a consumer on another origin fails. It is written here because
105
+ > nothing else would tell you.
106
+
59
107
  ## Documentation
60
108
 
61
109
  Full documentation — getting started and concepts, the `GislClient` reference and operation
package/dist/builder.js CHANGED
@@ -26,7 +26,7 @@
26
26
  */
27
27
  import { SseEventType, SseOperationProgressDataFromJSON, } from '@giveitsmaller/contracts/openapi';
28
28
  import { uploadSource } from './types.js';
29
- import { GislTimeoutError, GislNetworkError, SseEndedWithoutTerminal } from './errors.js';
29
+ import { GislTimeoutError, GislFanOutTimeoutError, GislNetworkError, SseEndedWithoutTerminal } from './errors.js';
30
30
  // Deferred-usage-only import: `Handle` is constructed inside submit() at call
31
31
  // time, not at module load, so the builder.ts <-> handle.ts cycle is safe
32
32
  // under ESM (handle.ts imports the await-primitives from this module).
@@ -446,13 +446,38 @@ export class MapEachBuilder {
446
446
  _checkAborted(options.signal);
447
447
  const remaining = deadline - Date.now();
448
448
  if (remaining <= 0) {
449
- throw new GislTimeoutError(`maxWait elapsed during fan-out (after ${collectedArtifacts.length} child runs)`);
449
+ // Clean timeout BETWEEN children (no child in flight): the parent + the
450
+ // children completed so far are recoverable — carry their ids so the
451
+ // caller polls them and re-runs ONLY the never-created children (4G4FaA9X).
452
+ throw new GislFanOutTimeoutError(`maxWait elapsed during fan-out (after ${collectedChildResults.length} child runs)`, {
453
+ completedWorkflowIds: collectedChildResults.map((r) => r.workflowId),
454
+ parentWorkflowId: parentResult.workflowId,
455
+ });
450
456
  }
451
457
  const childBuilder = this.fn(art);
452
- const childResult = await childBuilder.run({
453
- ...options,
454
- maxWait: remaining,
455
- });
458
+ let childResult;
459
+ try {
460
+ childResult = await childBuilder.run({
461
+ ...options,
462
+ maxWait: remaining,
463
+ });
464
+ }
465
+ catch (err) {
466
+ // A CHILD's own deadline elapsed mid-run — the COMMON fan-out timeout
467
+ // path. Re-throw as a fan-out timeout so the parent + already-completed
468
+ // children + this in-flight child are ALL recoverable, instead of losing
469
+ // them behind the child's bare GislTimeoutError (4G4FaA9X). Other errors
470
+ // (config / API / item failure) propagate unchanged.
471
+ if (err instanceof GislTimeoutError) {
472
+ throw new GislFanOutTimeoutError(`maxWait elapsed during fan-out while a child was running (${collectedChildResults.length} completed)`, {
473
+ completedWorkflowIds: collectedChildResults.map((r) => r.workflowId),
474
+ parentWorkflowId: parentResult.workflowId,
475
+ workflowId: err.workflowId,
476
+ cause: err,
477
+ });
478
+ }
479
+ throw err;
480
+ }
456
481
  collectedChildResults.push(childResult);
457
482
  for (const childArt of childResult.artifacts)
458
483
  collectedArtifacts.push(childArt);
@@ -85,6 +85,24 @@ export declare function resolveOutputRoute(inputToken: string, outputFormat: str
85
85
  * `compressMetadata` `per_value_availability`; same_format only (the only route
86
86
  * where value-level options like `metadata` are honored). Returns false when the
87
87
  * option / value / group is unknown (no gate).
88
+ *
89
+ * PURELY ADDITIVE (SB1wmTJz): planned if ANY consulted group marks this value planned.
90
+ * The historical group is still consulted, so **every verdict this returned before still
91
+ * holds** — the change can only turn a missed gate into a gate, never a gate into a
92
+ * pass. That direction matters: a new false ACCEPT would send a request the server
93
+ * rejects, which is the failure this function exists to prevent.
94
+ *
95
+ * Why not "most specific wins", which reads cleaner: it would flip `webp` +
96
+ * `color_profile: 'srgb'` from gated to un-gated, because `image_webp` defines
97
+ * `color_profile` with an empty `per_value_availability`. `RecipeOutputTest`
98
+ * deliberately pins webp srgb as GATED (v2.134 added `srgb: planned` to the generic
99
+ * group), and whether webp srgb actually works on the server is not something this
100
+ * layer can know. Un-gating it on an inference would be exactly the "confident answer
101
+ * from a check that could not tell you otherwise" pattern. Raised as a question instead.
102
+ *
103
+ * What this DOES fix: `image_svg` marks `output_format: 'original'` planned and the
104
+ * generic group does not, so an SVG input previously sailed through the one marker that
105
+ * mattered for it — on this gate and on the `output()` gate that shares it.
88
106
  */
89
107
  export declare function isPlannedValue(inputToken: string, optionKey: string, value: unknown): boolean;
90
108
  /**
@@ -117,4 +135,53 @@ export declare const COMPRESS_OPTION_VALUES: Readonly<Record<string, Readonly<Re
117
135
  * treated as unknown rather than coerced to a match.
118
136
  */
119
137
  export declare function isUnknownEnumValue(inputToken: string, optionKey: string, value: unknown): boolean;
138
+ /** A contract `depends_on` rule for a compress-image output option (ehHU08Hu). */
139
+ type OutputDependsOnRule = {
140
+ readonly requiresKey: string;
141
+ readonly requiresValue: string;
142
+ } | {
143
+ readonly requiresAnyOf: readonly string[];
144
+ };
145
+ /**
146
+ * Contract `depends_on` per compress-image output option, mirroring
147
+ * `availability.json` `operations.compress.mime_groups.<group>.options.<opt>.depends_on`
148
+ * (ehHU08Hu). The rule is option-consistent across every image group that carries
149
+ * the option, so this is a FLAT table (validated group-by-group by
150
+ * `output-route-conformance.test.ts` / PHP `ImageOutputRouteConformanceTest`).
151
+ *
152
+ * Kept as a hand table — NOT a runtime read of the ~238KB availability sidecar —
153
+ * so the gate stays browser-safe with no contracts-version coupling, exactly like
154
+ * {@link COMPRESS_OPTION_VALUES}. Mirrored by PHP
155
+ * `ImageOutputRoutes::OUTPUT_OPTION_DEPENDS_ON`.
156
+ *
157
+ * Generalises the 86gAu5Tr auto_quality gate: every option's dependency is
158
+ * checked uniformly, so quality/lossless/target_size_bytes under `auto_quality`,
159
+ * `target_size_bytes` without `target_size`, `fit` without width/height, etc. are
160
+ * all rejected pre-upload instead of only the one hand-coded case.
161
+ */
162
+ export declare const OUTPUT_OPTION_DEPENDS_ON: Readonly<Record<string, OutputDependsOnRule>>;
163
+ /**
164
+ * Default of each depended-on key — an ABSENT key resolves to this before the
165
+ * dependency check (the server applies the same default). `encoding_mode`
166
+ * defaults to `quality`, so `quality`/`lossless` are valid with no explicit mode,
167
+ * but `target_size_bytes` / `quality_preset` are not. Pinned to `availability.json`
168
+ * defaults by the conformance suite.
169
+ */
170
+ export declare const DEPENDS_ON_KEY_DEFAULTS: Readonly<Record<string, string>>;
171
+ /**
172
+ * The first contract `depends_on` an already-lowered compress-image wire-option
173
+ * set violates for the resolved `route`, or `undefined` when every dependency is
174
+ * satisfied (ehHU08Hu). The caller ({@link Recipe} output lowering) throws
175
+ * `invalid_option_combination` with the returned message + conflictingFields.
176
+ * Only options PRESENT in `wireOptions` are checked; a scalar dependency reads
177
+ * the depended-on key's effective value ({@link DEPENDS_ON_KEY_DEFAULTS} when
178
+ * absent). A scalar (encoding_mode) dependency is skipped on a `format_change`
179
+ * (convert has its own deps); universal deps (e.g. `fit → width|height`, identical
180
+ * in compress + convert) run on BOTH routes. Mirrored by PHP
181
+ * `ImageOutputRoutes::dependsOnViolation`.
182
+ */
183
+ export declare function dependsOnViolation(wireOptions: Readonly<Record<string, unknown>>, route: 'same_format' | 'format_change'): {
184
+ readonly message: string;
185
+ readonly conflictingFields: readonly string[];
186
+ } | undefined;
120
187
  export {};
@@ -146,15 +146,38 @@ export function resolveOutputRoute(inputToken, outputFormat) {
146
146
  planned: new Set(cell.planned),
147
147
  };
148
148
  }
149
- /** Input token → its `compress.image*` mime-group name (for per-value availability lookup). */
150
- function compressGroupForToken(token) {
151
- if (token === 'jpeg')
152
- return 'image_jpeg';
153
- if (token === 'png')
154
- return 'image_png';
155
- if (token === 'avif')
156
- return 'image_avif';
157
- return 'image'; // webp / gif / svg / tiff
149
+ /**
150
+ * Input token → EVERY `compress.image*` mime-group that can carry a per-value
151
+ * availability marker for it: the format-specific group when the metadata has one,
152
+ * PLUS the generic `image` group. Most specific first.
153
+ *
154
+ * Both are needed, and the old single-group version lost one or the other whichever
155
+ * way it chose (SB1wmTJz):
156
+ * - The generic group carries CROSS-FORMAT markers — `color_profile: 'srgb'` is planned
157
+ * there and nowhere else, so a lookup that resolved only to `image_jpeg` never saw it.
158
+ * - A specific group carries FORMAT-ONLY markers — `image_svg` marks
159
+ * `output_format: 'original'` planned (SVG→SVG optimisation is not built) and the
160
+ * generic group does not, so a lookup that resolved only to `image` never saw THAT.
161
+ *
162
+ * The previous implementation hard-coded `jpeg|png|avif` and fell through to `image`
163
+ * with a trailing `// webp / gif / svg / tiff`. That comment was true when written and
164
+ * silently stopped being true when `image_svg` and `image_webp` were added to the
165
+ * metadata — so SVG inputs missed the one marker that mattered for them, on this gate
166
+ * AND on the `output()` gate that shares it. Deriving the list from the metadata rather
167
+ * than a hand-written token list is what stops it going stale a second time; the
168
+ * mapping is pinned by `output-route-conformance.test.ts`.
169
+ *
170
+ * `gif`/`tiff` correctly yield `['image']` alone — the metadata genuinely has no
171
+ * concrete group for them (verified against its actual key set, not inferred).
172
+ */
173
+ function compressGroupsForToken(token) {
174
+ // The historical mapping, PRESERVED EXACTLY. Every verdict it produced today must
175
+ // keep being produced — see the note on additivity in `isPlannedValue`.
176
+ const legacy = token === 'jpeg' ? 'image_jpeg' : token === 'png' ? 'image_png' : token === 'avif' ? 'image_avif' : 'image';
177
+ const specific = `image_${token}`;
178
+ return specific !== legacy && compressMetadata.mime_groups[specific] !== undefined
179
+ ? [specific, legacy]
180
+ : [legacy];
158
181
  }
159
182
  /**
160
183
  * Whether a specific VALUE of an option is `availability: 'planned'` for the
@@ -163,14 +186,32 @@ function compressGroupForToken(token) {
163
186
  * `compressMetadata` `per_value_availability`; same_format only (the only route
164
187
  * where value-level options like `metadata` are honored). Returns false when the
165
188
  * option / value / group is unknown (no gate).
189
+ *
190
+ * PURELY ADDITIVE (SB1wmTJz): planned if ANY consulted group marks this value planned.
191
+ * The historical group is still consulted, so **every verdict this returned before still
192
+ * holds** — the change can only turn a missed gate into a gate, never a gate into a
193
+ * pass. That direction matters: a new false ACCEPT would send a request the server
194
+ * rejects, which is the failure this function exists to prevent.
195
+ *
196
+ * Why not "most specific wins", which reads cleaner: it would flip `webp` +
197
+ * `color_profile: 'srgb'` from gated to un-gated, because `image_webp` defines
198
+ * `color_profile` with an empty `per_value_availability`. `RecipeOutputTest`
199
+ * deliberately pins webp srgb as GATED (v2.134 added `srgb: planned` to the generic
200
+ * group), and whether webp srgb actually works on the server is not something this
201
+ * layer can know. Un-gating it on an inference would be exactly the "confident answer
202
+ * from a check that could not tell you otherwise" pattern. Raised as a question instead.
203
+ *
204
+ * What this DOES fix: `image_svg` marks `output_format: 'original'` planned and the
205
+ * generic group does not, so an SVG input previously sailed through the one marker that
206
+ * mattered for it — on this gate and on the `output()` gate that shares it.
166
207
  */
167
208
  export function isPlannedValue(inputToken, optionKey, value) {
168
- const group = compressMetadata.mime_groups[compressGroupForToken(inputToken)];
169
- const opt = group?.options[optionKey];
170
- if (opt === undefined)
171
- return false;
172
- const entry = opt.per_value_availability[String(value)];
173
- return entry?.availability === 'planned';
209
+ for (const groupName of compressGroupsForToken(inputToken)) {
210
+ const opt = compressMetadata.mime_groups[groupName]?.options[optionKey];
211
+ if (opt?.per_value_availability[String(value)]?.availability === 'planned')
212
+ return true;
213
+ }
214
+ return false;
174
215
  }
175
216
  /**
176
217
  * Compress-route enum members per image mime-group, mirroring the shipped
@@ -232,3 +273,93 @@ export function isUnknownEnumValue(inputToken, optionKey, value) {
232
273
  return false;
233
274
  return !(typeof value === 'string' && members.includes(value));
234
275
  }
276
+ /**
277
+ * Contract `depends_on` per compress-image output option, mirroring
278
+ * `availability.json` `operations.compress.mime_groups.<group>.options.<opt>.depends_on`
279
+ * (ehHU08Hu). The rule is option-consistent across every image group that carries
280
+ * the option, so this is a FLAT table (validated group-by-group by
281
+ * `output-route-conformance.test.ts` / PHP `ImageOutputRouteConformanceTest`).
282
+ *
283
+ * Kept as a hand table — NOT a runtime read of the ~238KB availability sidecar —
284
+ * so the gate stays browser-safe with no contracts-version coupling, exactly like
285
+ * {@link COMPRESS_OPTION_VALUES}. Mirrored by PHP
286
+ * `ImageOutputRoutes::OUTPUT_OPTION_DEPENDS_ON`.
287
+ *
288
+ * Generalises the 86gAu5Tr auto_quality gate: every option's dependency is
289
+ * checked uniformly, so quality/lossless/target_size_bytes under `auto_quality`,
290
+ * `target_size_bytes` without `target_size`, `fit` without width/height, etc. are
291
+ * all rejected pre-upload instead of only the one hand-coded case.
292
+ */
293
+ export const OUTPUT_OPTION_DEPENDS_ON = {
294
+ quality: { requiresKey: 'encoding_mode', requiresValue: 'quality' },
295
+ lossless: { requiresKey: 'encoding_mode', requiresValue: 'quality' },
296
+ quality_preset: { requiresKey: 'encoding_mode', requiresValue: 'auto_quality' },
297
+ target_size_bytes: { requiresKey: 'encoding_mode', requiresValue: 'target_size' },
298
+ fit: { requiresAnyOf: ['width', 'height'] },
299
+ };
300
+ /**
301
+ * Default of each depended-on key — an ABSENT key resolves to this before the
302
+ * dependency check (the server applies the same default). `encoding_mode`
303
+ * defaults to `quality`, so `quality`/`lossless` are valid with no explicit mode,
304
+ * but `target_size_bytes` / `quality_preset` are not. Pinned to `availability.json`
305
+ * defaults by the conformance suite.
306
+ */
307
+ export const DEPENDS_ON_KEY_DEFAULTS = {
308
+ encoding_mode: 'quality',
309
+ };
310
+ /**
311
+ * The first contract `depends_on` an already-lowered compress-image wire-option
312
+ * set violates for the resolved `route`, or `undefined` when every dependency is
313
+ * satisfied (ehHU08Hu). The caller ({@link Recipe} output lowering) throws
314
+ * `invalid_option_combination` with the returned message + conflictingFields.
315
+ * Only options PRESENT in `wireOptions` are checked; a scalar dependency reads
316
+ * the depended-on key's effective value ({@link DEPENDS_ON_KEY_DEFAULTS} when
317
+ * absent). A scalar (encoding_mode) dependency is skipped on a `format_change`
318
+ * (convert has its own deps); universal deps (e.g. `fit → width|height`, identical
319
+ * in compress + convert) run on BOTH routes. Mirrored by PHP
320
+ * `ImageOutputRoutes::dependsOnViolation`.
321
+ */
322
+ export function dependsOnViolation(wireOptions, route) {
323
+ for (const [option, rule] of Object.entries(OUTPUT_OPTION_DEPENDS_ON)) {
324
+ // A nullish value is NOT "set" — the contract `set` condition needs a real
325
+ // value, and PHP drops null options before lowering, so treat null == absent
326
+ // for parity (codex: `{ fit: 'max', width: null }` must reject, not bypass).
327
+ if (wireOptions[option] == null)
328
+ continue;
329
+ if ('requiresAnyOf' in rule) {
330
+ if (!rule.requiresAnyOf.some((key) => wireOptions[key] != null)) {
331
+ return {
332
+ conflictingFields: [option, ...rule.requiresAnyOf],
333
+ message: `output(): '${option}' requires at least one of ${rule.requiresAnyOf.join(', ')} to be set ` +
334
+ `(its contract dependency). Set ${rule.requiresAnyOf.join(' or ')}, or drop '${option}'.`,
335
+ };
336
+ }
337
+ continue;
338
+ }
339
+ // Scalar deps in this (compress-image) table are all on `encoding_mode`, a
340
+ // same_format optimiser key — validate them on same_format ONLY. The
341
+ // universal requiresAnyOf dep (fit → width|height) above runs on BOTH routes.
342
+ //
343
+ // A format_change routes via `convert`, which has no encoding_mode and carries
344
+ // its own deps — but those need NO table here (L2Ay7Uak, resolved as a no-op).
345
+ // Every convert image dep is keyed on `output_format`, and the per-target
346
+ // `honored` set the lowering already enforces IS that constraint materialised:
347
+ // `output('gif', { quality: 80 })` is rejected by the honored gate, with a
348
+ // better message, before this function runs. That equivalence is PINNED by
349
+ // `output-route-conformance.test.ts` (+ the PHP mirror), which fails closed if
350
+ // convert ever gains a dep keyed on something other than output_format — which
351
+ // is the case that would genuinely need a gate here.
352
+ if (route !== 'same_format')
353
+ continue;
354
+ const effective = wireOptions[rule.requiresKey] ?? DEPENDS_ON_KEY_DEFAULTS[rule.requiresKey];
355
+ if (effective !== rule.requiresValue) {
356
+ return {
357
+ conflictingFields: [rule.requiresKey, option],
358
+ message: `output(): '${option}' requires ${rule.requiresKey} '${rule.requiresValue}' (its contract ` +
359
+ `dependency), but ${rule.requiresKey} is '${String(effective)}'. Set ${rule.requiresKey}: ` +
360
+ `'${rule.requiresValue}', or drop '${option}'.`,
361
+ };
362
+ }
363
+ }
364
+ return undefined;
365
+ }
@@ -137,6 +137,13 @@ export interface WatermarkOptions {
137
137
  * overlays on one base image (z-order = array index). MUTUALLY EXCLUSIVE with
138
138
  * the flat single-overlay options above; the server rejects mixing the two as
139
139
  * `invalid_options`. image_watermark jpeg/png/webp bases only.
140
+ *
141
+ * NOTE: NOT usable via `watermark()` yet — the facade composites a single
142
+ * overlay (the positional `overlay`, wire source src_1), so `overlays[]` would
143
+ * reference sources it cannot create. `watermark()` rejects it at lowering
144
+ * (`overlays_unsupported`); use the flat single-overlay options above instead.
145
+ * Kept as a valid contract wire key — multi-overlay stacking is a future
146
+ * feature (Vbbdq9C4).
140
147
  */
141
148
  overlays?: WatermarkOverlay[];
142
149
  }
@@ -154,9 +161,11 @@ export type OutputMetadata = 'strip' | 'keep';
154
161
  * Compression mode on the optimiser (same_format) route (contract `encoding_mode`
155
162
  * enum). `quality` (default) drives the encode by the quality slider; `target_size`
156
163
  * targets a byte budget via the worker's encode-measure loop — STABLE since
157
- * contracts v2.108.0 (jpeg/webp/avif).
164
+ * contracts v2.108.0 (jpeg/webp/avif). `auto_quality` lets the worker pick the
165
+ * quality from a named `quality_preset` (its `depends_on`) — the output lowering
166
+ * infers it for you when you set `quality_preset` without an `encoding_mode`.
158
167
  */
159
- export type OutputEncodingMode = 'quality' | 'target_size';
168
+ export type OutputEncodingMode = 'quality' | 'target_size' | 'auto_quality';
160
169
  /** Chroma subsampling for JPEG output (contract `chroma_subsampling` enum, v2.110.0). `420` smallest → `444` highest fidelity. Honored: same_format jpeg only. */
161
170
  export type OutputChromaSubsampling = '420' | '422' | '444';
162
171
  /** ICC colour-profile handling (contract `color_profile` enum, v2.112.0). `keep` preserves the embedded profile; `srgb` converts to sRGB; `strip` removes it. Route/value availability is gated by the output lowering. */
@@ -76,6 +76,28 @@ export interface ResolveCompressOptionsOutput {
76
76
  */
77
77
  export declare function _parseTargetSize(value: unknown): number;
78
78
  export declare const KNOWN_WIRE_FIELDS: Readonly<Record<PresetMedia, ReadonlySet<string>>>;
79
+ /**
80
+ * Compress options that are `availability: planned` per mime-group, mirroring the
81
+ * shipped `availability/availability.json`
82
+ * `operations.compress.mime_groups.<group>.options.<opt>.availability`.
83
+ *
84
+ * Kept as a hand table (NOT a runtime read of the ~238KB availability sidecar) for
85
+ * the same reasons as {@link IMAGE_OUTPUT_ROUTES}: the gate stays browser-safe, and
86
+ * — decisively — it has NO dependency on which `@giveitsmaller/contracts` version a
87
+ * consumer resolved. A generated-metadata read would FAIL OPEN on an older published
88
+ * contracts (the rtkzl9gr failure mode), and fail-open is the wrong direction for a
89
+ * gate whose entire job is to fail closed.
90
+ *
91
+ * PINNED to `availability.json` by `tests/unit/preset-planned-conformance.test.ts`,
92
+ * which fails closed in BOTH directions — a contract regen that marks a new option
93
+ * `planned`, or unmarks one, breaks the build rather than the caller. Mirrored by PHP
94
+ * `PresetResolver::PLANNED_COMPRESS_OPTIONS`.
95
+ *
96
+ * `video.speed` is listed for a faithful projection even though no shipped preset
97
+ * cell emits it; the conformance test pins the whole projection, not just the keys
98
+ * we happen to use today.
99
+ */
100
+ export declare const PLANNED_COMPRESS_OPTIONS: Readonly<Record<PresetMedia, ReadonlySet<string>>>;
79
101
  /**
80
102
  * Resolve the wire payload + introspection projection for a compress
81
103
  * operation call. Throws {@link GislConfigError} before any network
@@ -251,6 +251,20 @@ const MEDIA_FIELDS = Object.freeze({
251
251
  document_odf: new Set(['stripMetadata', 'stripUnusedStyles']),
252
252
  document_epub: new Set(['fontSubsetting', 'stripUnusedCss']),
253
253
  });
254
+ const OUT = (key) => ({ verb: 'output', key });
255
+ const CROSS_VERB_OVERRIDES = Object.freeze({
256
+ image: Object.freeze({
257
+ width: OUT('width'), height: OUT('height'), fit: OUT('fit'),
258
+ autoOrient: OUT('auto_orient'), colorProfile: OUT('color_profile'),
259
+ progressive: OUT('progressive'), lossless: OUT('lossless'),
260
+ qualityPreset: OUT('quality_preset'), encodingMode: OUT('encoding_mode'),
261
+ targetSizeBytes: OUT('target_size_bytes'), chromaSubsampling: OUT('chroma_subsampling'),
262
+ optimizationLevel: OUT('optimization_level'), avifSpeed: OUT('avif_speed'),
263
+ }),
264
+ // convert() takes the target format positionally, not as an option.
265
+ audio: Object.freeze({ outputFormat: { verb: 'convert', key: null } }),
266
+ video: Object.freeze({ outputFormat: { verb: 'convert', key: null } }),
267
+ });
254
268
  function detectMismatchedOverrides(media, overrides) {
255
269
  const expected = MEDIA_FIELDS[media];
256
270
  const keys = Object.keys(overrides);
@@ -264,6 +278,37 @@ function detectMismatchedOverrides(media, overrides) {
264
278
  const unknownFields = keys.filter((k) => !expected.has(k));
265
279
  if (unknownFields.length === 0)
266
280
  return;
281
+ // Options that are real for THIS media but belong to another verb are
282
+ // answered with that verb, before the other-media guess below — otherwise a
283
+ // key both this media and another one recognises (image `width` vs video
284
+ // `width`) gets blamed on the wrong media.
285
+ // Classified PER FIELD, not all-or-nothing: `{ width, codec }` on an image
286
+ // must still tell the caller that `width` is a legal resize on output(),
287
+ // rather than reverting to "you passed video options" for the pair.
288
+ const crossVerb = CROSS_VERB_OVERRIDES[media];
289
+ const crossVerbFields = crossVerb === undefined ? [] : unknownFields.filter((k) => crossVerb[k] !== undefined);
290
+ if (crossVerbFields.length > 0 && crossVerb !== undefined) {
291
+ const strays = unknownFields.filter((k) => crossVerb[k] === undefined);
292
+ const byVerb = new Map();
293
+ for (const k of crossVerbFields) {
294
+ const target = crossVerb[k];
295
+ const list = byVerb.get(target.verb) ?? [];
296
+ list.push(target.key ?? k);
297
+ byVerb.set(target.verb, list);
298
+ }
299
+ const clauses = [...byVerb.entries()].map(([verb, keys]) => verb === 'convert'
300
+ ? `${keys.join(', ')} is the format argument of convert()`
301
+ : `${keys.join(', ')} ${keys.length === 1 ? 'is an option' : 'are options'} on ${verb}()`);
302
+ const outputKeys = byVerb.get('output');
303
+ throw new GislConfigError(`presetOverrides for '${media}' contained ${crossVerbFields.join(', ')}, which ${crossVerbFields.length === 1 ? 'does' : 'do'} not belong on the compress preset surface: ${clauses.join('; ')}.` +
304
+ (strays.length > 0 ? ` Also unrecognised for '${media}': ${strays.join(', ')}.` : ''), {
305
+ reason: 'type_mismatch',
306
+ conflictingFields: unknownFields,
307
+ suggestion: outputKeys !== undefined
308
+ ? `Move ${outputKeys.join(', ')} to output(): .output(format, { ${outputKeys[0]}: … }).`
309
+ : 'Use convert(format) to change the output format.',
310
+ });
311
+ }
267
312
  // Look up which OTHER media owns every unknown field — if a single
268
313
  // OTHER media's field set covers them all, that's a type_mismatch.
269
314
  for (const otherMedia of Object.keys(MEDIA_FIELDS)) {
@@ -319,6 +364,35 @@ export const KNOWN_WIRE_FIELDS = Object.freeze({
319
364
  document_odf: new Set(['strip_metadata', 'strip_unused_styles']),
320
365
  document_epub: new Set(['font_subsetting', 'strip_unused_css']),
321
366
  });
367
+ /**
368
+ * Compress options that are `availability: planned` per mime-group, mirroring the
369
+ * shipped `availability/availability.json`
370
+ * `operations.compress.mime_groups.<group>.options.<opt>.availability`.
371
+ *
372
+ * Kept as a hand table (NOT a runtime read of the ~238KB availability sidecar) for
373
+ * the same reasons as {@link IMAGE_OUTPUT_ROUTES}: the gate stays browser-safe, and
374
+ * — decisively — it has NO dependency on which `@giveitsmaller/contracts` version a
375
+ * consumer resolved. A generated-metadata read would FAIL OPEN on an older published
376
+ * contracts (the rtkzl9gr failure mode), and fail-open is the wrong direction for a
377
+ * gate whose entire job is to fail closed.
378
+ *
379
+ * PINNED to `availability.json` by `tests/unit/preset-planned-conformance.test.ts`,
380
+ * which fails closed in BOTH directions — a contract regen that marks a new option
381
+ * `planned`, or unmarks one, breaks the build rather than the caller. Mirrored by PHP
382
+ * `PresetResolver::PLANNED_COMPRESS_OPTIONS`.
383
+ *
384
+ * `video.speed` is listed for a faithful projection even though no shipped preset
385
+ * cell emits it; the conformance test pins the whole projection, not just the keys
386
+ * we happen to use today.
387
+ */
388
+ export const PLANNED_COMPRESS_OPTIONS = Object.freeze({
389
+ image: new Set([]),
390
+ audio: new Set([]),
391
+ video: new Set(['speed']),
392
+ document_office: new Set(['strip_hidden_data', 'strip_macros', 'strip_unused_fonts']),
393
+ document_odf: new Set(['strip_metadata', 'strip_unused_styles']),
394
+ document_epub: new Set(['font_subsetting', 'strip_unused_css']),
395
+ });
322
396
  function validateMerged(media, merged, explicitKeys, winners) {
323
397
  // Unknown-field defence-in-depth: every key must belong to the
324
398
  // media's wire surface OR be one of the resolver-derived wire keys
@@ -527,6 +601,26 @@ export function resolveCompressOptions(input) {
527
601
  delete acc.merged.bitrate;
528
602
  acc.winners.delete('bitrate');
529
603
  }
604
+ // A shipped preset must never put an `availability: planned` option on the wire.
605
+ // The API rejects a planned option when the KEY IS PRESENT and ignores it when
606
+ // absent (`CreateWorkflowCommandHandler::recordPlannedFromMap` — a materialized
607
+ // default deliberately does NOT trigger it), so a value WE synthesized becomes a
608
+ // 422 `feature_not_available` at create that the caller never asked for. That is
609
+ // what broke every document compress with an `optimizeFor` in 0.21.0 (5Eksm9s7).
610
+ //
611
+ // Drop ONLY the sdkDefault-sourced ones. A planned option from any other layer is
612
+ // a caller choice — `clientDefault` and `scopedDefault` are user-registered via
613
+ // `gisl.create({ presetDefaults })` / `client.withPresetDefaults(...)`, not baked
614
+ // in by us — and is left in place for the server to refuse honestly. Same rule and
615
+ // same reason as the lossless-bitrate drop above: never silently swallow a key the
616
+ // caller chose. Do NOT "simplify" either of these into an always-drop; a silent
617
+ // no-op on an explicit request is worse than an honest 422.
618
+ for (const key of PLANNED_COMPRESS_OPTIONS[media]) {
619
+ if (acc.winners.get(key) === 'sdkDefault') {
620
+ delete acc.merged[key];
621
+ acc.winners.delete(key);
622
+ }
623
+ }
530
624
  // 7. Validate the merged payload (post-merge — catches cross-layer
531
625
  // disagreements). May throw GislConfigError with resolvedSnapshot.
532
626
  const explicitWireKeys = new Set();
@@ -12,10 +12,22 @@
12
12
  // `mode` + `iccProfile` were REMOVED — the worker is lossy-only and always
13
13
  // strips metadata, so advertising a lossless mode or ICC-profile policy was
14
14
  // an over-claim. `progressive` is still a per-JPEG wire option but is no
15
- // longer carried in the preset cell. `width`/`height`/`fit`/`autoOrient`
16
- // were removed earlier — the image-compress worker never resized (resize-fit
17
- // lives on thumbnail/convert; video keeps its own fit). Per-call knobs are
18
- // deliberately excluded they belong on the per-call argument shape.
15
+ // longer carried in the preset cell.
16
+ //
17
+ // `width`/`height`/`fit`/`autoOrient` were removed earlier on the grounds that
18
+ // "the image-compress worker never resized". CAREFUL that is still true of
19
+ // the Rust optimiser crate and NOT true end-to-end. Since contract v2.97.0
20
+ // ("resize lives inside Output") the API canonicalises an image compress
21
+ // carrying width/height/fit into a `convert` op, and convert IS the resize
22
+ // engine, so such a request returns a genuinely resized file. Their absence
23
+ // here is therefore a SURFACE choice, not a capability limit: resize is
24
+ // expressed via `output()` (see `OutputOptions`), which the compress
25
+ // conformance gate records as CROSS_VERB_ROUTING and self-verifies. Reading
26
+ // this comment as "unsupported" is what produced cySAEZHR. Whether compress()
27
+ // should ALSO carry them is an open ergonomic-expansion decision, not a bug.
28
+ //
29
+ // Per-call knobs are deliberately excluded — they belong on the per-call
30
+ // argument shape.
19
31
  import { shippedDefaultsFor as f3ShippedDefaultsFor } from '../../generated/sdk_spec/presets.js';
20
32
  import { translateEnum } from './_translate.js';
21
33
  export class ImageCompressPresetOptions {
@@ -1,6 +1,20 @@
1
1
  import { VideoCodec, VideoPreset, VideoFit, AudioCodec, AudioBitrate, OptimizeFor } from '../../generated/sdk_spec/enums.js';
2
2
  export interface VideoCompressPresetOptionsInput {
3
3
  readonly codec?: VideoCodec;
4
+ /**
5
+ * Target output size (`'50MB'`-style string — BINARY units, 1 KB = 1024 — or a byte
6
+ * count). Derived by the resolver into `target_size_bytes` + `encoding_mode:
7
+ * 'target_size'`.
8
+ *
9
+ * **NOT AVAILABLE FOR LONG INPUTS.** A compress whose input duration routes to the
10
+ * long-form path rejects it (`reject_long_form_target_size`): that path is
11
+ * single-pass-CRF by construction and two-pass target-size is unbuilt. The request
12
+ * fails during execution, and the SDK cannot warn earlier — routing is decided
13
+ * server-side at create-plan time, so there is nothing here to check it against.
14
+ * Short-form compresses honour it normally. Tracked by `zJN6XIi5`, blocked on a
15
+ * contract that can express per-execution-path availability. The same limit applies
16
+ * to {@link MergeOptions.targetSize}.
17
+ */
4
18
  readonly targetSize?: string | number;
5
19
  readonly crf?: number;
6
20
  readonly preset?: VideoPreset;
package/dist/errors.d.ts CHANGED
@@ -159,6 +159,21 @@ export declare class GislBalanceExhaustedError extends GislApiError {
159
159
  export declare class GislLongFormConcurrencyError extends GislApiError {
160
160
  readonly payload: LongFormConcurrencyLimitResponse;
161
161
  constructor(statusCode: number, errorMessage: string, payload: LongFormConcurrencyLimitResponse, path?: string, extra?: Omit<GislApiErrorOptions, 'payload'>);
162
+ /**
163
+ * ALWAYS `false`, overriding the base 429-implies-retryable heuristic
164
+ * (UO1xYecu). This 429 is not a rate limit: it carries no `Retry-After` and
165
+ * clears only when an in-flight long-form workflow finishes, so a back-off
166
+ * retries into a wall that no amount of waiting-then-retrying opens. The base
167
+ * accessor reported `true` purely from the status, contradicting this class's
168
+ * own documented handling ("wait on completion or upgrade — do NOT back off")
169
+ * and instructing the one recovery that cannot work.
170
+ *
171
+ * Overridden per-class rather than via a code table because this is the only
172
+ * such code today; the general fix — an explicit taxonomy verdict outranking
173
+ * the status heuristic — arrives with the `error-taxonomy.yaml` `retryable`
174
+ * enum (contracts `plwcAqBr`), tracked on UO1xYecu.
175
+ */
176
+ get retryable(): boolean;
162
177
  /** The pricing / upgrade deep link (`links.upgrade`), or `undefined` when absent. */
163
178
  get upgradeUrl(): string | undefined;
164
179
  }
@@ -459,6 +474,44 @@ export declare class GislTimeoutError extends GislError {
459
474
  readonly workflowId?: string;
460
475
  constructor(message: string, workflowId?: string);
461
476
  }
477
+ /**
478
+ * A `mapEach` fan-out timed out mid-batch — the deadline elapsed either while a
479
+ * child was still running (the common case) or cleanly between child runs. The
480
+ * parent and some children have ALREADY completed, so re-running the whole batch
481
+ * re-does finished work. This carries their ids so the caller can poll them (via
482
+ * `client.getWorkflowStatus` / `getWorkflowDownloads`) to recover the finished
483
+ * work and re-run ONLY the children that were never created.
484
+ *
485
+ * Subclasses {@link GislTimeoutError}, so an existing
486
+ * `catch (e) { if (e instanceof GislTimeoutError) … }` still catches it. The
487
+ * inherited `workflowId` carries the IN-FLIGHT child — the one that was running
488
+ * when the deadline elapsed (a child's own timeout, the common path) — or stays
489
+ * `undefined` when the deadline elapsed cleanly BETWEEN children (no in-flight
490
+ * child). To recover, poll `workflowId` (if set) + {@link parentWorkflowId} +
491
+ * {@link completedWorkflowIds}, then re-run only the children that never started.
492
+ *
493
+ * NOTE on double-charge: the server-side create-dedupe (DSxwCetg) is what
494
+ * prevents a byte-identical child re-create from settling a SECOND charge within
495
+ * the dedup window; this error's job is efficient RECOVERY (skip the completed
496
+ * work) + defense-in-depth, not the sole charge guard.
497
+ */
498
+ export declare class GislFanOutTimeoutError extends GislTimeoutError {
499
+ /** The child workflows that completed before the deadline elapsed. */
500
+ readonly completedWorkflowIds: readonly string[];
501
+ /** The parent workflow, which ran to completion before the fan-out began. */
502
+ readonly parentWorkflowId?: string;
503
+ constructor(message: string, opts: {
504
+ completedWorkflowIds: readonly string[];
505
+ parentWorkflowId?: string;
506
+ /**
507
+ * The in-flight child that timed out mid-run (its own deadline elapsed);
508
+ * `undefined` for a clean between-children timeout with no child running.
509
+ */
510
+ workflowId?: string;
511
+ /** The underlying child {@link GislTimeoutError}, preserved for chaining. */
512
+ cause?: unknown;
513
+ });
514
+ }
462
515
  /**
463
516
  * Transport-level failure: the underlying `fetch` (or other transport) could
464
517
  * not produce a usable response — DNS, TCP, TLS, a mid-stream disconnect, or a
package/dist/errors.js CHANGED
@@ -172,6 +172,23 @@ export class GislLongFormConcurrencyError extends GislApiError {
172
172
  super(statusCode, errorMessage, path, undefined, buildOptionsWithPayload(payload, extra));
173
173
  this.name = 'GislLongFormConcurrencyError';
174
174
  }
175
+ /**
176
+ * ALWAYS `false`, overriding the base 429-implies-retryable heuristic
177
+ * (UO1xYecu). This 429 is not a rate limit: it carries no `Retry-After` and
178
+ * clears only when an in-flight long-form workflow finishes, so a back-off
179
+ * retries into a wall that no amount of waiting-then-retrying opens. The base
180
+ * accessor reported `true` purely from the status, contradicting this class's
181
+ * own documented handling ("wait on completion or upgrade — do NOT back off")
182
+ * and instructing the one recovery that cannot work.
183
+ *
184
+ * Overridden per-class rather than via a code table because this is the only
185
+ * such code today; the general fix — an explicit taxonomy verdict outranking
186
+ * the status heuristic — arrives with the `error-taxonomy.yaml` `retryable`
187
+ * enum (contracts `plwcAqBr`), tracked on UO1xYecu.
188
+ */
189
+ get retryable() {
190
+ return false;
191
+ }
175
192
  /** The pricing / upgrade deep link (`links.upgrade`), or `undefined` when absent. */
176
193
  get upgradeUrl() {
177
194
  return this.payload.links?.upgrade;
@@ -505,6 +522,43 @@ export class GislTimeoutError extends GislError {
505
522
  this.workflowId = workflowId === '' ? undefined : workflowId;
506
523
  }
507
524
  }
525
+ /**
526
+ * A `mapEach` fan-out timed out mid-batch — the deadline elapsed either while a
527
+ * child was still running (the common case) or cleanly between child runs. The
528
+ * parent and some children have ALREADY completed, so re-running the whole batch
529
+ * re-does finished work. This carries their ids so the caller can poll them (via
530
+ * `client.getWorkflowStatus` / `getWorkflowDownloads`) to recover the finished
531
+ * work and re-run ONLY the children that were never created.
532
+ *
533
+ * Subclasses {@link GislTimeoutError}, so an existing
534
+ * `catch (e) { if (e instanceof GislTimeoutError) … }` still catches it. The
535
+ * inherited `workflowId` carries the IN-FLIGHT child — the one that was running
536
+ * when the deadline elapsed (a child's own timeout, the common path) — or stays
537
+ * `undefined` when the deadline elapsed cleanly BETWEEN children (no in-flight
538
+ * child). To recover, poll `workflowId` (if set) + {@link parentWorkflowId} +
539
+ * {@link completedWorkflowIds}, then re-run only the children that never started.
540
+ *
541
+ * NOTE on double-charge: the server-side create-dedupe (DSxwCetg) is what
542
+ * prevents a byte-identical child re-create from settling a SECOND charge within
543
+ * the dedup window; this error's job is efficient RECOVERY (skip the completed
544
+ * work) + defense-in-depth, not the sole charge guard.
545
+ */
546
+ export class GislFanOutTimeoutError extends GislTimeoutError {
547
+ /** The child workflows that completed before the deadline elapsed. */
548
+ completedWorkflowIds;
549
+ /** The parent workflow, which ran to completion before the fan-out began. */
550
+ parentWorkflowId;
551
+ constructor(message, opts) {
552
+ // The inherited workflowId is the in-flight child (or undefined between children).
553
+ super(message, opts.workflowId);
554
+ this.name = 'GislFanOutTimeoutError';
555
+ this.completedWorkflowIds = [...opts.completedWorkflowIds];
556
+ this.parentWorkflowId = opts.parentWorkflowId === '' ? undefined : opts.parentWorkflowId;
557
+ if (opts.cause !== undefined) {
558
+ this.cause = opts.cause;
559
+ }
560
+ }
561
+ }
508
562
  /**
509
563
  * Transport-level failure: the underlying `fetch` (or other transport) could
510
564
  * not produce a usable response — DNS, TCP, TLS, a mid-stream disconnect, or a
@@ -14,7 +14,7 @@ import { _detectCompressMedia, _detectAudioLossless, _consumeSseToTerminal, _pol
14
14
  import { LazyHttpDownloader } from './lazy-downloader.js';
15
15
  import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
16
16
  import { validateVerbOptions, assertThumbnailDimensions } from './ergonomic/option_validation.js';
17
- import { resolveOutputRoute, tokenForMime, tokenForPath, isPlannedValue, isUnknownEnumValue, FACADE_MANAGED_OUTPUTS, } from './ergonomic/image_output_routes.js';
17
+ import { resolveOutputRoute, tokenForMime, tokenForPath, isPlannedValue, isUnknownEnumValue, dependsOnViolation, FACADE_MANAGED_OUTPUTS, } from './ergonomic/image_output_routes.js';
18
18
  import { OptimizeFor } from './generated/sdk_spec/enums.js';
19
19
  import { uploadSource, jobOutputSource } from './types.js';
20
20
  // Value import used only at call-time (inside MergedRecipe.toWorkflowPayload),
@@ -1101,7 +1101,7 @@ export class Recipe {
1101
1101
  if (requested !== undefined && FACADE_MANAGED_OUTPUTS.includes(requested)) {
1102
1102
  const facade = { output_format: requested };
1103
1103
  for (const [key, value] of Object.entries(step.options)) {
1104
- if (key === 'output_format' || value === undefined)
1104
+ if (key === 'output_format' || value === undefined || value === null)
1105
1105
  continue;
1106
1106
  if (key !== 'quality') {
1107
1107
  throw new GislConfigError(`output(): '${key}' needs a detectable input format to route; reference the file by ` +
@@ -1122,7 +1122,9 @@ export class Recipe {
1122
1122
  }
1123
1123
  const wireOptions = { output_format: resolved.outputFormatWire };
1124
1124
  for (const [key, value] of Object.entries(step.options)) {
1125
- if (key === 'output_format' || value === undefined)
1125
+ // Drop a null value (as PHP does) so a null option never reaches the wire
1126
+ // and is treated as absent by the depends_on gate — full null parity (codex).
1127
+ if (key === 'output_format' || value === undefined || value === null)
1126
1128
  continue;
1127
1129
  if (resolved.planned.has(key)) {
1128
1130
  throw new GislConfigError(`output(): '${key}' is advertised but not available yet on the ${resolved.route} route ` +
@@ -1147,6 +1149,28 @@ export class Recipe {
1147
1149
  }
1148
1150
  wireOptions[key] = value;
1149
1151
  }
1152
+ // quality_preset's contract `depends_on: { encoding_mode: auto_quality }`
1153
+ // (86gAu5Tr) — infer the mode when the caller set NONE so the preset forms a
1154
+ // VALID request. SAME_FORMAT only: encoding_mode is a compress optimiser and
1155
+ // quality_preset isn't honored on a format_change. An explicitly-conflicting
1156
+ // mode is rejected by the general depends_on gate below.
1157
+ if (resolved.route === 'same_format' &&
1158
+ wireOptions.quality_preset !== undefined &&
1159
+ wireOptions.encoding_mode === undefined) {
1160
+ wireOptions.encoding_mode = 'auto_quality';
1161
+ }
1162
+ // General contract `depends_on` validation (ehHU08Hu), scoped per route: the
1163
+ // universal fit→width|height dep runs on BOTH routes (identical in compress +
1164
+ // convert); the encoding_mode-family deps run on same_format only. Subsumes
1165
+ // the 86gAu5Tr auto_quality gate plus target_size_bytes-without-target_size,
1166
+ // fit-without-width/height, and any future compress-image depends_on.
1167
+ const dependency = dependsOnViolation(wireOptions, resolved.route);
1168
+ if (dependency !== undefined) {
1169
+ throw new GislConfigError(dependency.message, {
1170
+ reason: 'invalid_option_combination',
1171
+ conflictingFields: [...dependency.conflictingFields],
1172
+ });
1173
+ }
1150
1174
  return { type: resolved.sourceOp, options: wireOptions };
1151
1175
  }
1152
1176
  /**
@@ -1466,9 +1490,22 @@ function _validateWatermarkOverlay(overlay) {
1466
1490
  }
1467
1491
  }
1468
1492
  function _lowerWatermarkOp(wireOp, options) {
1469
- // Watermark options (anchor/opacity/margin_x/margin_y/overlay_width, or the
1470
- // multi-overlay overlays[] stack) are already wire keys; empty options omit
1471
- // the `options` key (byte-identical to PHP).
1493
+ // `overlays[]` (the multi-overlay stack) is a live contract option but is NOT
1494
+ // reachable through watermark(): the facade composites exactly ONE overlay
1495
+ // the positional `overlay` (wire source src_1) — so overlays[1..] reference
1496
+ // sources it cannot create, any entry is invalid on a non-image base, and the
1497
+ // contract's `minItems: 1` makes an empty array invalid too. Reject it here at
1498
+ // lowering (mutation-safe — reads the FINAL options, catching a post-watermark()
1499
+ // `opts.overlays = [...]`) and point callers at the single-overlay knobs.
1500
+ // Real multi-overlay stacking is a future feature (Vbbdq9C4).
1501
+ if (options.overlays !== undefined) {
1502
+ throw new GislConfigError("watermark(): 'overlays[]' (multi-overlay stacking) is not supported — watermark() composites a " +
1503
+ 'single overlay (the positional overlay argument). Use the top-level anchor / opacity / margin_x / ' +
1504
+ 'margin_y / overlay_width options to place it. Multi-overlay stacking is a future feature.', { reason: 'overlays_unsupported', conflictingFields: ['overlays'] });
1505
+ }
1506
+ // The remaining watermark options (anchor/opacity/margin_x/margin_y/
1507
+ // overlay_width) are already wire keys; empty options omit the `options` key
1508
+ // (byte-identical to PHP).
1472
1509
  const wire = { ...options };
1473
1510
  return Object.keys(wire).length === 0 ? { type: wireOp } : { type: wireOp, options: wire };
1474
1511
  }
@@ -1489,6 +1526,14 @@ function _lowerWatermarkOp(wireOp, options) {
1489
1526
  */
1490
1527
  async function _uploadInputsAndCreate(client, inputs, toPayload, opts) {
1491
1528
  const { webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs, uploadsLabel, workflowLabel } = opts;
1529
+ // Preflight: lower the composed chains with placeholder ids so a route-invalid
1530
+ // option (or any lowering-time gate — overlays, sole_op split, route/enum) in
1531
+ // ANY input's chain — a watermark base/overlay, a merge/archive member — throws
1532
+ // BEFORE we spend a single upload byte. The multi-input analog of the
1533
+ // single-input Recipe.assertOperationsLowerable preflight (0azjb6Rg); mirrors
1534
+ // PHP. The placeholder ids never reach the wire — the payload is discarded
1535
+ // (T3ltXsou). toWorkflowPayload is pure, so re-lowering at create is cheap.
1536
+ toPayload(inputs.map((_, i) => `preflight_${i}`));
1492
1537
  const fileIds = [];
1493
1538
  // Track each freshly-uploaded input's probe-gate inputs (a pre-uploaded id
1494
1539
  // carries no local mime/size, so it is excluded — never probed).
@@ -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" | "validation_error" | "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" | "upload_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" | "image_dimensions_too_large" | "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" | "validation_error" | "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" | "upload_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" | "image_dimensions_too_large" | "upload_failed" | "workflow_failed" | "long_form_concurrency_limit_exceeded" | "unprocessable_entity" | "email_same" | "config_error" | "bundle_already_archived" | "fan_out_timeout" | "no_such_key" | "result_not_ready" | "sink_error" | "item_failed";
2
2
  export type ErrorCategory = 'api' | 'config' | 'network' | 'auth' | 'validation' | 'chain';
3
3
  export type ErrorStatus = 'wired' | 'planned';
4
4
  export interface ErrorEntry {
@@ -377,7 +377,7 @@ export const ERROR_CODES = Object.freeze({
377
377
  status: "wired",
378
378
  httpStatus: 422,
379
379
  retryable: true,
380
- sdkClass: "GislApiError",
380
+ sdkClass: "GislProbePendingError",
381
381
  description: "422 on workflow create — upload probing not yet complete; retry after the upload finishes probing. Wire `error_type: \"probe_pending\"`.",
382
382
  metadataSchema: Object.freeze({
383
383
  "jobRef": "string",
@@ -506,6 +506,142 @@ export const ERROR_CODES = Object.freeze({
506
506
  "jobErrors": "array",
507
507
  }),
508
508
  }),
509
+ "long_form_concurrency_limit_exceeded": Object.freeze({
510
+ code: "long_form_concurrency_limit_exceeded",
511
+ category: "api",
512
+ source: "ErrorEnvelope.error",
513
+ status: "wired",
514
+ httpStatus: 429,
515
+ retryable: false,
516
+ sdkClass: "GislLongFormConcurrencyError",
517
+ description: "429 — the caller's tier long-form concurrency allowance is exhausted. Wire value is UPPERCASE `LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED` (api.yaml:1995); this file's `code:` follows the taxonomy's lowercase convention. Dispatched on the machine `error` code, NOT on the status: a generic infra rate-limit 429 carries a different/absent code and falls through to base GislApiError where retryAfterSeconds applies. Do not widen this entry to the status.",
518
+ metadataSchema: Object.freeze({
519
+ "currentTier": "string",
520
+ "maxDurationSeconds": "number",
521
+ }),
522
+ }),
523
+ "unprocessable_entity": Object.freeze({
524
+ code: "unprocessable_entity",
525
+ category: "auth",
526
+ source: "error_type",
527
+ status: "wired",
528
+ httpStatus: 422,
529
+ retryable: false,
530
+ sdkClass: "GislAuthRejectionError",
531
+ description: "422 on an auth-side-effect endpoint (register / verify-email / api-keys) — the request was well-formed but rejected on domain grounds. Dispatched on MEMBERSHIP of AuthRejectionEnvelope.error_type, per ADR-0019.",
532
+ metadataSchema: Object.freeze({
533
+ "errorType": "string",
534
+ }),
535
+ }),
536
+ "email_same": Object.freeze({
537
+ code: "email_same",
538
+ category: "auth",
539
+ source: "error_type",
540
+ status: "wired",
541
+ httpStatus: 422,
542
+ retryable: false,
543
+ sdkClass: "GislAuthRejectionError",
544
+ description: "422 on profile PATCH — the submitted email matches the current one, so there is nothing to change. Same envelope and sdkClass as unprocessable_entity; separate row because the wire value differs.",
545
+ metadataSchema: Object.freeze({
546
+ "errorType": "string",
547
+ }),
548
+ }),
549
+ "config_error": Object.freeze({
550
+ code: "config_error",
551
+ category: "config",
552
+ source: "SDK_local",
553
+ status: "wired",
554
+ httpStatus: null,
555
+ retryable: false,
556
+ sdkClass: "GislConfigError",
557
+ description: "Client-side configuration rejected before any request — thrown directly (preset resolver, output() gates), not only as a base class. `reason` carries the discriminator: `unknown_field` and `type_mismatch` are values of THIS field and deliberately have no rows of their own.",
558
+ metadataSchema: Object.freeze({
559
+ "reason": "string",
560
+ "conflictingFields": "array",
561
+ "resolvedSnapshot": "object",
562
+ "suggestion": "string",
563
+ }),
564
+ }),
565
+ "bundle_already_archived": Object.freeze({
566
+ code: "bundle_already_archived",
567
+ category: "config",
568
+ source: "SDK_local",
569
+ status: "planned",
570
+ httpStatus: null,
571
+ retryable: false,
572
+ sdkClass: "GislBundleAlreadyArchivedError",
573
+ description: "PLANNED — not reachable in any shipped build. Raised when a bundle operation targets an already-archived bundle, once `.bundle()` ships. Do not write handler code against this yet.",
574
+ metadataSchema: Object.freeze({}),
575
+ }),
576
+ "fan_out_timeout": Object.freeze({
577
+ code: "fan_out_timeout",
578
+ category: "network",
579
+ source: "SDK_local",
580
+ status: "wired",
581
+ httpStatus: null,
582
+ retryable: true,
583
+ sdkClass: "GislFanOutTimeoutError",
584
+ description: "A fan-out deadline elapsed before all children finished. The three metadata fields are the reason this is declared separately from GislTimeoutError: completedWorkflowIds are the children that DID finish, parentWorkflowId ran to completion before the fan-out began, and the inherited workflowId is the in-flight child — absent on a clean between-children timeout.",
585
+ metadataSchema: Object.freeze({
586
+ "completedWorkflowIds": "array",
587
+ "parentWorkflowId": "string",
588
+ "workflowId": "string",
589
+ }),
590
+ }),
591
+ "no_such_key": Object.freeze({
592
+ code: "no_such_key",
593
+ category: "chain",
594
+ source: "SDK_local",
595
+ status: "wired",
596
+ httpStatus: null,
597
+ retryable: false,
598
+ sdkClass: "GislNoSuchKeyError",
599
+ description: "The caller asked for a mapEach result key that does not exist in the output. NOTE the key itself is NOT captured in metadata today — it is interpolated into the message only, so consumers cannot branch on which key was missing.",
600
+ metadataSchema: Object.freeze({}),
601
+ }),
602
+ "result_not_ready": Object.freeze({
603
+ code: "result_not_ready",
604
+ category: "validation",
605
+ source: "SDK_local",
606
+ status: "wired",
607
+ httpStatus: null,
608
+ retryable: true,
609
+ sdkClass: "GislResultNotReadyError",
610
+ description: "The caller read a result before the workflow reached a terminal state. `state` carries the non-terminal status observed.",
611
+ metadataSchema: Object.freeze({
612
+ "workflowId": "string",
613
+ "state": "string",
614
+ }),
615
+ }),
616
+ "sink_error": Object.freeze({
617
+ code: "sink_error",
618
+ category: "config",
619
+ source: "SDK_local",
620
+ status: "wired",
621
+ httpStatus: null,
622
+ retryable: false,
623
+ sdkClass: "GislSinkError",
624
+ description: "A caller-supplied output sink could not be used. Branch on `reason` — the values span both caller setup (invalid_directory, duplicate_filename) and runtime write outcomes (write_failed, partial_failure), so the category is a best fit rather than an exact one.",
625
+ metadataSchema: Object.freeze({
626
+ "reason": "string",
627
+ }),
628
+ }),
629
+ "item_failed": Object.freeze({
630
+ code: "item_failed",
631
+ category: "api",
632
+ source: "SDK_local",
633
+ status: "wired",
634
+ httpStatus: null,
635
+ retryable: false,
636
+ sdkClass: "GislItemFailedError",
637
+ description: "CARRIED, not thrown — an entry in a per-item failed[] collection describing one item's server-side failure. `errorCode` carries the server's own code where present; prefer branching on that over this entry.",
638
+ metadataSchema: Object.freeze({
639
+ "key": "string",
640
+ "state": "string",
641
+ "errorMessage": "string",
642
+ "errorCode": "string",
643
+ }),
644
+ }),
509
645
  });
510
646
  export const ERROR_CATEGORIES = Object.freeze({
511
647
  api: Object.freeze([
@@ -522,20 +658,28 @@ export const ERROR_CATEGORIES = Object.freeze({
522
658
  "requires_reencode",
523
659
  "image_dimensions_too_large",
524
660
  "workflow_failed",
661
+ "long_form_concurrency_limit_exceeded",
662
+ "item_failed",
525
663
  ]),
526
664
  config: Object.freeze([
527
665
  "missing_credentials",
528
666
  "feature_requires_auth",
667
+ "config_error",
668
+ "bundle_already_archived",
669
+ "sink_error",
529
670
  ]),
530
671
  network: Object.freeze([
531
672
  "timeout",
532
673
  "aborted",
533
674
  "upload_failed",
675
+ "fan_out_timeout",
534
676
  ]),
535
677
  auth: Object.freeze([
536
678
  "auth_failed",
537
679
  "multipart_session_ownership",
538
680
  "multipart_session_auth_required",
681
+ "unprocessable_entity",
682
+ "email_same",
539
683
  ]),
540
684
  validation: Object.freeze([
541
685
  "multipart_part_invalid",
@@ -551,11 +695,13 @@ export const ERROR_CATEGORIES = Object.freeze({
551
695
  "missing_dependency",
552
696
  "unsupported_value",
553
697
  "type_mismatch",
698
+ "result_not_ready",
554
699
  ]),
555
700
  chain: Object.freeze([
556
701
  "undeclared_asset",
557
702
  "unused_asset",
558
703
  "per_input_options_not_supported",
559
704
  "chain_cardinality_mismatch",
705
+ "no_such_key",
560
706
  ]),
561
707
  });
@@ -3,7 +3,7 @@ export { parseSseStream } from './sse.js';
3
3
  export type { CreditsUsageOptions, ListWorkflowsOptions, GetSchemaOptions, GetSchemaResult, CapabilitiesSnapshot, PreflightClipError, PreflightClipsResult, ProbeWaitOptions, ProbeWaitResult, GislClientConfig, GislSseEvent, GislSseParseFailure, UploadOptions, WaitOptions, WorkflowCreatePayload, OperationDef, WorkflowSourcePayload, MultiInputSourcePayload, UploadSourcePayload, JobOutputSourcePayload, ExternalImportSourcePayload, ConnectionSourcePayload, JobInputV2Payload, JobDefinitionPayload, ExternalDestinationPayload, DeliveryPayload, DeliveryModePayload, DeliveryBundleFormatPayload, DeliverySelectionPayload, DeliverySelectionTypePayload, DeliveryOutputRefPayload, WorkflowProcessingPayload, ProcessingClassHintPayload, MultipartCheckpointState, _Sdk3HandCodedUploadedPart, _Sdk3HandCodedMultipartStatusResult, _Sdk3HandCodedPresignedPart, _Sdk3HandCodedPresignPartsResult, _Sdk3HandCodedKeepaliveResult, } from './types.js';
4
4
  export { uploadSource, jobOutputSource, externalImportSource, connectionSource, } from './types.js';
5
5
  export type { GislConfigErrorMetadata } from './errors.js';
6
- export { GislError, GislApiError, GislValidationError, GislBalanceExhaustedError, GislLongFormConcurrencyError, GislTierRestrictedError, GislFeatureTierRestrictedError, GislFeatureNotAvailableError, GislWorkflowExpiredError, GislProbePendingError, GislAuthError, GislUploadCapExceededError, GislMultipartPartError, GislMultipartPartCountError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTimeoutError, GislAbortError, GislNetworkError, GislConfigError, GislMissingCredentialsError, GislFeatureRequiresAuthError, GislUndeclaredAssetError, GislUnusedAssetError, GislPerInputOptionsNotSupportedError, GislChainCardinalityMismatchError, GislBundleAlreadyArchivedError, GislNoSuchKeyError, GislSinkError, GislItemFailedError, GislResultNotReadyError, } from './errors.js';
6
+ export { GislError, GislApiError, GislValidationError, GislBalanceExhaustedError, GislLongFormConcurrencyError, GislTierRestrictedError, GislFeatureTierRestrictedError, GislFeatureNotAvailableError, GislWorkflowExpiredError, GislProbePendingError, GislAuthError, GislUploadCapExceededError, GislMultipartPartError, GislMultipartPartCountError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTimeoutError, GislFanOutTimeoutError, GislAbortError, GislNetworkError, GislConfigError, GislMissingCredentialsError, GislFeatureRequiresAuthError, GislUndeclaredAssetError, GislUnusedAssetError, GislPerInputOptionsNotSupportedError, GislChainCardinalityMismatchError, GislBundleAlreadyArchivedError, GislNoSuchKeyError, GislSinkError, GislItemFailedError, GislResultNotReadyError, } from './errors.js';
7
7
  export type { GislApiErrorOptions, GislUploadCapKind } from './errors.js';
8
8
  export type { ErrorCategory } from './generated/sdk_spec/errors.js';
9
9
  export { RunResult } from './file-first.js';
@@ -11,7 +11,10 @@ export { uploadSource, jobOutputSource, externalImportSource, connectionSource,
11
11
  // Errors
12
12
  export { GislError, GislApiError, GislValidationError, GislBalanceExhaustedError, GislLongFormConcurrencyError, GislTierRestrictedError, GislFeatureTierRestrictedError, GislFeatureNotAvailableError, GislWorkflowExpiredError, GislProbePendingError, GislAuthError, GislUploadCapExceededError, GislMultipartPartError, GislMultipartPartCountError,
13
13
  // SDK-3 (Wb6ebOMM) — typed errors for the 3 resume-support endpoints.
14
- GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTimeoutError, GislAbortError,
14
+ GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTimeoutError,
15
+ // 4G4FaA9X — mapEach fan-out timed out mid-batch; carries the completed
16
+ // child ids + parent id so the caller can recover without a whole-batch re-run.
17
+ GislFanOutTimeoutError, GislAbortError,
15
18
  // FF2b / tywwynmN — transport-level failure (mirrors PHP GislNetworkError);
16
19
  // raised by the file-first HttpDownloader when an output URL cannot be read.
17
20
  GislNetworkError,
package/dist/merge.d.ts CHANGED
@@ -103,6 +103,29 @@ export interface MergeOptions {
103
103
  readonly preset?: string;
104
104
  /** Video output dimensions `WxH` (e.g. `"1920x1080"`); omit to inherit from inputs. Video merge only. */
105
105
  readonly targetResolution?: string;
106
+ /**
107
+ * Target output size (bytes, or a `'50MB'`-style string). Lowered to wire
108
+ * `target_size_bytes`, and the SDK also sets `encoding_mode: 'target_size'` alongside
109
+ * it. Video merge only.
110
+ *
111
+ * **UNITS ARE DECIMAL HERE (1 KB = 1000), UNLIKE `compress`.** `compress`'s
112
+ * `targetSize` parses the same strings as BINARY (1 KB = 1024), so `'50MB'` means
113
+ * 50,000,000 bytes on a merge and 52,428,800 bytes on a compress. That divergence is
114
+ * NOT deliberate — it contradicts the pinned convention that every human-readable
115
+ * size string in this SDK is binary — and it has a sharp edge: the contract floor for
116
+ * `target_size_bytes` is 1 MiB (1,048,576), so `'1MB'` here resolves to 1,000,000 and
117
+ * is rejected as below the minimum. Prefer an explicit byte count until this is
118
+ * reconciled. Tracked by `YOCz0i74`; changing it moves bytes for existing callers, so
119
+ * it is a deliberate decision rather than a silent correction.
120
+ *
121
+ * **NOT AVAILABLE FOR LONG INPUTS.** Merges whose summed input duration routes to
122
+ * the long-form Fargate path reject both keys — that path is single-pass-CRF by
123
+ * construction and two-pass target-size is unbuilt. The request fails during
124
+ * execution, and the SDK cannot warn earlier: the routing decision is made
125
+ * server-side at create-plan time, so there is nothing here to check it against.
126
+ * Short-form merges honour it normally. Tracked by `zJN6XIi5`, blocked on a
127
+ * contract that can express per-execution-path availability.
128
+ */
106
129
  readonly targetSize?: string | number;
107
130
  readonly transitionDuration?: number;
108
131
  readonly fps?: number;
package/dist/sse.js CHANGED
@@ -105,7 +105,55 @@ export async function* parseSseStream(response, opts = {}) {
105
105
  continue;
106
106
  }
107
107
  if (line.startsWith(':')) {
108
- // Comment line (keep-alive), skip
108
+ // Comment line (keep-alive), skip.
109
+ //
110
+ // ⚠️ DO NOT "FIX" THIS INTO SURFACING COMMENT FRAMES WITHOUT READING
111
+ // THIS. Dropping them is CORRECT per the SSE spec — a comment carries
112
+ // no event — but it is also LOAD-BEARING FOR A CONSUMER, and that
113
+ // consumer is in another repo.
114
+ //
115
+ // The frontend does NOT close its SSE reader on the terminal event:
116
+ // `workflow_completed` patches status and breaks WITHOUT aborting.
117
+ // What actually closes the stream is an IDLE WATCHDOG at 20s. That
118
+ // watchdog only fires because heartbeats are never yielded here, so
119
+ // they never re-arm it — roughly 20s after the last real event the
120
+ // reader closes and the PHP worker is released.
121
+ //
122
+ // HEARTBEAT ~16s vs IDLE TIMEOUT 20s: a FOUR-SECOND MARGIN that holds
123
+ // only while the heartbeats are invisible. Surfacing comment frames —
124
+ // a completely reasonable change, since EventSource semantics treat
125
+ // keep-alive comments as liveness signals that legitimately reset
126
+ // timeouts — would CONTINUOUSLY re-arm the watchdog. The stream would
127
+ // never go idle, and every completed job left on screen would hold a
128
+ // PHP worker for the full 570s. That is worker exhaustion from
129
+ // ordinary users leaving tabs open.
130
+ //
131
+ // A ONE-LINE CHANGE HERE SILENTLY CONVERTS THE FRONTEND FROM BOUNDED
132
+ // TO UNBOUNDED, and nothing on either side would flag it.
133
+ //
134
+ // ⚠️ THIS NOTE IS PERMANENT. DO NOT DELETE IT WHEN THE FRONTEND
135
+ // ADDS AN EXPLICIT CLOSE-ON-TERMINAL. That fix removes the TERMINAL
136
+ // case and does NOT remove the dependency, because closing finished
137
+ // streams was never the watchdog's job — that was a side effect
138
+ // nobody knew about until 2026-08-12.
139
+ //
140
+ // THE WATCHDOG'S ACTUAL JOB IS DETECTING A STREAM THAT HAS GONE
141
+ // SILENT WHILE THE WORKFLOW IS STILL RUNNING, and resuming the
142
+ // fallback poll. That case survives every fix in flight, and it is
143
+ // not exotic: a long operation between progress events produces it
144
+ // exactly — heartbeats flowing, no data frames, job still running.
145
+ //
146
+ // If this parser surfaced comment frames, heartbeats at ~16s would
147
+ // re-arm the 20s watchdog FOREVER and STALL DETECTION WOULD NEVER
148
+ // FIRE AT ALL, on a stream that is genuinely stuck. The UI would go
149
+ // on believing a dead job is fine. That is worse than the worker
150
+ // leak: a leak is bounded by 570s, a missed stall is not bounded at
151
+ // all.
152
+ //
153
+ // The PHP SDK does the same thing at GislClient.php (`$line[0] === ':'`),
154
+ // verified 2026-08-12 — the two languages agree on this axis, and
155
+ // they must stay agreed: this property governs whether a stuck
156
+ // stream is EVER detected, so a divergence here is not cosmetic.
109
157
  continue;
110
158
  }
111
159
  const colonIndex = line.indexOf(':');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giveitsmaller/sdk",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Node.js SDK for the GISL (Give It Smaller) file compression and processing API",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "node": ">=18"
32
32
  },
33
33
  "dependencies": {
34
- "@giveitsmaller/contracts": "^0.59.0"
34
+ "@giveitsmaller/contracts": "^0.65.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^22",