@giveitsmaller/sdk 0.20.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.
Files changed (40) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +55 -14
  3. package/dist/_audit.js +2 -4
  4. package/dist/builder.d.ts +4 -4
  5. package/dist/builder.js +47 -22
  6. package/dist/client.d.ts +9 -6
  7. package/dist/client.js +90 -31
  8. package/dist/ergonomic/image_output_routes.d.ts +97 -0
  9. package/dist/ergonomic/image_output_routes.js +227 -25
  10. package/dist/ergonomic/option_types.d.ts +11 -2
  11. package/dist/ergonomic/preset_resolver.d.ts +24 -2
  12. package/dist/ergonomic/preset_resolver.js +100 -10
  13. package/dist/ergonomic/presets/image_compress.js +16 -4
  14. package/dist/ergonomic/presets/index.d.ts +9 -8
  15. package/dist/ergonomic/presets/index.js +1 -9
  16. package/dist/ergonomic/presets/video_compress.d.ts +14 -0
  17. package/dist/errors.d.ts +101 -2
  18. package/dist/errors.js +108 -1
  19. package/dist/file-first.d.ts +81 -13
  20. package/dist/file-first.js +329 -69
  21. package/dist/generated/sdk_spec/enums.d.ts +0 -26
  22. package/dist/generated/sdk_spec/enums.js +0 -16
  23. package/dist/generated/sdk_spec/errors.d.ts +1 -1
  24. package/dist/generated/sdk_spec/errors.js +159 -1
  25. package/dist/generated/sdk_spec/presets.js +0 -14
  26. package/dist/generated/sdk_spec/version.d.ts +2 -2
  27. package/dist/generated/sdk_spec/version.js +2 -2
  28. package/dist/gisl.d.ts +21 -2
  29. package/dist/handle.d.ts +6 -1
  30. package/dist/handle.js +42 -13
  31. package/dist/index.core.d.ts +4 -4
  32. package/dist/index.core.js +6 -3
  33. package/dist/merge.d.ts +23 -0
  34. package/dist/merge.js +2 -2
  35. package/dist/sse.js +49 -1
  36. package/dist/types.d.ts +11 -3
  37. package/dist/types.js +1 -0
  38. package/package.json +3 -3
  39. package/dist/ergonomic/presets/document_pdf_compress.d.ts +0 -12
  40. package/dist/ergonomic/presets/document_pdf_compress.js +0 -33
package/dist/client.js CHANGED
@@ -4,8 +4,8 @@
4
4
  // blobByteSource, which never touches these). Kept as a STATIC import (not a
5
5
  // dynamic one) so `vi.mock('node:fs/promises')` still intercepts it in tests.
6
6
  import { open, stat, basename } from './node-fs.js';
7
- import { AudioWatermarkDecodeRequestToJSON, AudioWatermarkDecodeResponseFromJSON, ExternalImportCreatedResponseFromJSON, ExternalImportRequestToJSON, LoginUser200ResponseDataFromJSON, AccountLimitsFromJSON, CreditsBalanceResponseFromJSON, CreditsUsageResponseFromJSON, UploadResponseFromJSON, UploadProbeResponseFromJSON, MultipartInitiateResponseFromJSON, MultipartInitiateRequestMetadataHintToJSON, MultipartCompleteResponseFromJSON, MultipartCompleteRequestToJSON, WorkflowCancelResponseFromJSON, WorkflowCreateResponseFromJSON, WorkflowResumeResponseFromJSON, WorkflowStatusResponseFromJSON, WorkflowListResponseFromJSON, WorkflowDownloadResponseFromJSON, MetadataResponseFromJSON, OperationsSchemaResponseFromJSON, RetryResponseFromJSON, WorkflowStatus, AuthErrorResponseFromJSON, AuthErrorType, AuthRejectionEnvelopeFromJSON, AuthRejectionEnvelopeErrorTypeEnum, BalanceExhaustedResponseFromJSON, BalanceExhaustedResponseRequiredActionEnum, FeatureNotAvailableResponseFromJSON, FeatureTierRestrictedResponseFromJSON, TierRestrictionKind, TierRestrictionResponseFromJSON, UserTier, WorkflowExpiredResponseFromJSON, ProbePendingResponseFromJSON, UploadSizeExceedsTierResponseFromJSON, UploadDurationExceedsTierResponseFromJSON, UploadConstraintsAppliedProcessingClassPreAssignmentEnum, UploadThresholdsSingleShotMaxBytesEnum, UploadThresholdsMultipartChunkSizeEnum, UploadThresholdsMultipartConcurrencyDefaultEnum, } from '@giveitsmaller/contracts/openapi';
8
- import { GislAbortError, GislApiError, GislAuthError, GislAuthRejectionError, GislBalanceExhaustedError, GislError, GislFeatureNotAvailableError, GislFeatureTierRestrictedError, GislMultipartPartCountError, GislMultipartPartError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTierRestrictedError, GislTimeoutError, GislProbePendingError, GislUploadCapExceededError, GislValidationError, GislWorkflowExpiredError, } from './errors.js';
7
+ import { AudioWatermarkDecodeRequestToJSON, AudioWatermarkDecodeResponseFromJSON, ExternalImportCreatedResponseFromJSON, ExternalImportRequestToJSON, LoginUser200ResponseDataFromJSON, AccountLimitsFromJSON, CreditsBalanceResponseFromJSON, CreditsUsageResponseFromJSON, UploadResponseFromJSON, UploadProbeResponseFromJSON, MultipartInitiateResponseFromJSON, MultipartInitiateRequestMetadataHintToJSON, MultipartCompleteResponseFromJSON, MultipartCompleteRequestToJSON, WorkflowCancelResponseFromJSON, WorkflowCreateResponseFromJSON, WorkflowResumeResponseFromJSON, WorkflowStatusResponseFromJSON, WorkflowListResponseFromJSON, WorkflowDownloadResponseFromJSON, MetadataResponseFromJSON, OperationsSchemaResponseFromJSON, RetryResponseFromJSON, WorkflowStatus, AuthErrorResponseFromJSON, AuthErrorType, AuthRejectionEnvelopeFromJSON, AuthRejectionEnvelopeErrorTypeEnum, BalanceExhaustedResponseFromJSON, BalanceExhaustedResponseRequiredActionEnum, FeatureNotAvailableResponseFromJSON, FeatureTierRestrictedResponseFromJSON, LongFormConcurrencyLimitResponseFromJSON, TierRestrictionKind, TierRestrictionResponseFromJSON, UserTier, WorkflowExpiredResponseFromJSON, ProbePendingResponseFromJSON, UploadSizeExceedsTierResponseFromJSON, UploadDurationExceedsTierResponseFromJSON, UploadConstraintsAppliedProcessingClassPreAssignmentEnum, UploadThresholdsSingleShotMaxBytesEnum, UploadThresholdsMultipartChunkSizeEnum, UploadThresholdsMultipartConcurrencyDefaultEnum, } from '@giveitsmaller/contracts/openapi';
8
+ import { GislAbortError, GislApiError, GislAuthError, GislAuthRejectionError, GislBalanceExhaustedError, GislError, GislFeatureNotAvailableError, GislFeatureTierRestrictedError, GislLongFormConcurrencyError, GislMultipartPartCountError, GislMultipartPartError, GislMultipartSessionNotFoundError, GislMultipartSessionOwnershipError, GislMultipartSessionAuthRequiredError, GislTierRestrictedError, GislTimeoutError, GislProbePendingError, GislUploadCapExceededError, GislValidationError, GislWorkflowExpiredError, } from './errors.js';
9
9
  // The `Retry-After` millisecond parser lives in the shared retry-metadata
10
10
  // module (extracted to break the client ↔ errors circular import); re-imported
11
11
  // here so the retry-loop timing stays byte-identical.
@@ -59,7 +59,25 @@ const S3_MAX_MULTIPART_PARTS = 10_000;
59
59
  // exists to prevent). codex review (high).
60
60
  const RECOMMENDED_CHUNK_SIZE_MAX_BYTES = 104_857_600; // 100 MiB
61
61
  const DEFAULT_POLL_INTERVAL_MS = 2_000;
62
- const DEFAULT_POLL_TIMEOUT_MS = 300_000; // 5 min
62
+ const DEFAULT_POLL_TIMEOUT_MS = 600_000; // 10 min
63
+ /**
64
+ * Re-tag a bare per-request transport {@link GislTimeoutError} with the workflow
65
+ * it was scoped to, so a timed-out workflow read (status / downloads) stays
66
+ * recoverable. No-op for any other error and for a timeout that already carries
67
+ * an id. See `oYumKo6y`. (PHP transport failures surface as `GislNetworkError`,
68
+ * so this enrichment is TS-only.)
69
+ */
70
+ function withWorkflowIdOnTimeout(err, workflowId) {
71
+ if (!(err instanceof GislTimeoutError) || err.workflowId !== undefined)
72
+ return err;
73
+ const enriched = new GislTimeoutError(err.message, workflowId);
74
+ // Preserve the original throw site + chain the cause — the enriched error is
75
+ // a re-tag, not a new failure, so diagnostics should still point at the
76
+ // transport timeout.
77
+ enriched.stack = err.stack;
78
+ enriched.cause = err;
79
+ return enriched;
80
+ }
63
81
  // Anonymous-read capability header. An anonymous (null-owner) workflow create
64
82
  // returns a one-time `cap` token (WorkflowCreateResponse.cap); the session-less
65
83
  // caller passes it back on status/downloads/events reads via this header so the
@@ -98,6 +116,18 @@ function headersToRecord(headers) {
98
116
  });
99
117
  return r;
100
118
  }
119
+ // Canonical human-readable fallback when a failure envelope carries NO `message`
120
+ // field. Kept IDENTICAL to the PHP SDK (`GislClient::fallbackErrorMessage`) so a
121
+ // message-absent error surfaces byte-identical `.message` text across SDKs
122
+ // (card U7MACpOj). Used by BOTH error-construction paths (`handleResponse` and
123
+ // the `getSchema` raw-response path) so they cannot drift. The machine code
124
+ // stays on `.errorCode` — this string is human DISPLAY text only; never parse
125
+ // it. `error` absent → `unknown_error` (PHP parity). Historically TS leaked the
126
+ // SCREAMING_SNAKE `error` code as `.message`, diverging from PHP's synthetic
127
+ // sentence; this unifies them on the human-shaped form.
128
+ function fallbackErrorMessage(status, errorCode) {
129
+ return `Request failed with status ${status} (${errorCode ?? 'unknown_error'}).`;
130
+ }
101
131
  function isValidationDetails(value) {
102
132
  return (Array.isArray(value) &&
103
133
  value.length > 0 &&
@@ -484,13 +514,16 @@ export class GislClient {
484
514
  };
485
515
  // Human-readable text comes from `message` (the I26 localised field).
486
516
  // `error` is the stable, never-localised SCREAMING_SNAKE machine code —
487
- // NOT display text. Surfacing `error` as the thrown error's `.message`
488
- // regressed consumers that render the human string (x9Lbf6uy). Fall back
489
- // to `error` when `message` is absent (deployed contract guarantees
490
- // `message` on conforming error envelopes). Machine dispatch keys off
491
- // `error_type` (below), unchanged.
517
+ // NOT display text (surfacing it as `.message` regressed consumers that
518
+ // render the human string, x9Lbf6uy). When `message` is absent (the
519
+ // deployed contract guarantees it on conforming error envelopes), fall
520
+ // back to the canonical synthetic sentence byte-identical to the PHP
521
+ // SDK — instead of leaking the machine code (card U7MACpOj). The code is
522
+ // still carried on `.errorCode`. Machine dispatch keys off `error_type`
523
+ // (below), unchanged.
492
524
  const status = response.status;
493
- const errorMessage = json.message ?? json.error ?? 'Unknown error';
525
+ const errorMessage = json.message ??
526
+ fallbackErrorMessage(status, typeof json.error === 'string' ? json.error : undefined);
494
527
  // Validation-details branch first — preserve existing shape so callers
495
528
  // matching on `instanceof GislValidationError` keep working.
496
529
  if (isValidationDetails(json.details)) {
@@ -595,6 +628,15 @@ export class GislClient {
595
628
  tryThrowCap(UploadDurationExceedsTierResponseFromJSON, 'duration_tier', (p) => isInEnum(p.currentTier, UserTier) &&
596
629
  typeof p.maxDurationSeconds === 'number');
597
630
  }
631
+ // Long-form concurrency limit (429) — a TIER quota, DISTINCT from a
632
+ // generic infra rate-limit 429. Dispatched on the machine `error` CODE
633
+ // (this envelope carries NO `error_type`); a generic rate-limit 429 has a
634
+ // different/absent code, so it falls through to the base GislApiError
635
+ // where `retryAfterSeconds` applies. The validator re-asserts the code so
636
+ // a malformed envelope falls through rather than mis-typing.
637
+ if (status === 429 && json.error === 'LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED') {
638
+ tryThrowStructured(LongFormConcurrencyLimitResponseFromJSON, GislLongFormConcurrencyError, (p) => p.error === 'LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED');
639
+ }
598
640
  // 413 = the absolute across-tier cap. The contract models 413 as a
599
641
  // plain `ErrorEnvelope` (no `error_type` discriminator, no typed
600
642
  // payload — api.yaml), so dispatch purely on status with no FromJSON
@@ -1603,10 +1645,15 @@ export class GislClient {
1603
1645
  * authenticated reads. A wrong/missing cap on a null-owner workflow is a 404.
1604
1646
  */
1605
1647
  async getWorkflowStatus(workflowId, opts = {}) {
1606
- return this.request('GET', `/api/workflows/${encodeURIComponent(workflowId)}/status`, {
1607
- deserialize: WorkflowStatusResponseFromJSON,
1608
- headers: workflowCapabilityHeaders(opts.capability),
1609
- });
1648
+ try {
1649
+ return await this.request('GET', `/api/workflows/${encodeURIComponent(workflowId)}/status`, {
1650
+ deserialize: WorkflowStatusResponseFromJSON,
1651
+ headers: workflowCapabilityHeaders(opts.capability),
1652
+ });
1653
+ }
1654
+ catch (err) {
1655
+ throw withWorkflowIdOnTimeout(err, workflowId);
1656
+ }
1610
1657
  }
1611
1658
  /**
1612
1659
  * Poll until the workflow reaches a terminal status.
@@ -1624,7 +1671,7 @@ export class GislClient {
1624
1671
  return status;
1625
1672
  }
1626
1673
  if (Date.now() + intervalMs > deadline) {
1627
- throw new GislTimeoutError(`Workflow ${workflowId} did not complete within ${timeoutMs}ms`);
1674
+ throw new GislTimeoutError(`Workflow ${workflowId} did not complete within ${timeoutMs}ms`, workflowId);
1628
1675
  }
1629
1676
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
1630
1677
  }
@@ -1681,10 +1728,15 @@ export class GislClient {
1681
1728
  * authenticated reads. A wrong/missing cap on a null-owner workflow is a 404.
1682
1729
  */
1683
1730
  async getWorkflowDownloads(workflowId, opts = {}) {
1684
- return this.request('GET', `/api/workflows/${encodeURIComponent(workflowId)}/downloads`, {
1685
- deserialize: WorkflowDownloadResponseFromJSON,
1686
- headers: workflowCapabilityHeaders(opts.capability),
1687
- });
1731
+ try {
1732
+ return await this.request('GET', `/api/workflows/${encodeURIComponent(workflowId)}/downloads`, {
1733
+ deserialize: WorkflowDownloadResponseFromJSON,
1734
+ headers: workflowCapabilityHeaders(opts.capability),
1735
+ });
1736
+ }
1737
+ catch (err) {
1738
+ throw withWorkflowIdOnTimeout(err, workflowId);
1739
+ }
1688
1740
  }
1689
1741
  /**
1690
1742
  * Stream SSE events for a workflow. Returns an async iterable.
@@ -1862,22 +1914,26 @@ export class GislClient {
1862
1914
  return { notModified: true, etag, lastModified };
1863
1915
  }
1864
1916
  if (!response.ok) {
1865
- let errorMessage = 'Unknown error';
1866
1917
  let errorCode;
1918
+ // Seed with the canonical synthetic sentence (unknown_error) so the
1919
+ // message-absent JSON branch below reuses the SAME helper as
1920
+ // handleResponse + PHP — that message-absent JSON envelope is the
1921
+ // U7MACpOj parity target. A non-JSON body is a rare edge that keeps
1922
+ // this seed (it does NOT claim byte-parity with PHP's non-JSON path,
1923
+ // which throws a distinct GislError).
1924
+ let errorMessage = fallbackErrorMessage(response.status, undefined);
1867
1925
  try {
1868
1926
  const errJson = (await response.json());
1869
- // Prefer the human `message`; `error` is the machine code (x9Lbf6uy).
1870
- if (errJson.message)
1871
- errorMessage = errJson.message;
1872
- else if (errJson.error)
1873
- errorMessage = errJson.error;
1874
1927
  // Surface the machine code as errorCode too (parity with handleResponse
1875
1928
  // + PHP), even when `message` supplied the human text.
1876
1929
  if (typeof errJson.error === 'string')
1877
1930
  errorCode = errJson.error;
1931
+ // Prefer the human `message`; else the canonical synthetic sentence —
1932
+ // NOT the raw machine code (x9Lbf6uy / U7MACpOj).
1933
+ errorMessage = errJson.message ?? fallbackErrorMessage(response.status, errorCode);
1878
1934
  }
1879
1935
  catch {
1880
- // Non-JSON body — keep generic message, no machine code.
1936
+ // Non-JSON body — keep the generic synthetic message, no machine code.
1881
1937
  }
1882
1938
  // This throw is OUTSIDE handleResponse (rawResponse:true / 304 path), so
1883
1939
  // build the response-header surface from the in-scope `response` here.
@@ -1949,15 +2005,18 @@ export class GislClient {
1949
2005
  * via that cookie when the client is configured with
1950
2006
  * `useSessionCookie: true`.
1951
2007
  *
1952
- * Failure modes per ticket FX6mbTJD:
1953
- * - **401** `invalid_credentials` (collapsed with unverified
1954
- * accounts for anti-enumeration) `GislAuthError`.
1955
- * - **403** account-state failures (`account_locked`,
1956
- * `account_disabled`, `account_deleted`,
1957
- * `account_deletion_expired`) → `GislAuthError`.
2008
+ * Failure modes per ticket FX6mbTJD (login narrowed at contracts
2009
+ * v2.166.0 authsec no 403 account-state branch on login):
2010
+ * - **401** `invalid_credentials` (wrong password, unverified, OR
2011
+ * unknown account all collapsed for anti-enumeration)
2012
+ * `GislAuthError`.
1958
2013
  * - **429** infrastructure rate-limit → `GislApiError` with
1959
2014
  * the `Retry-After` header echoed on the response.
1960
2015
  *
2016
+ * The account-status error types (`account_locked` / `account_disabled`
2017
+ * / `account_deleted` / `account_deletion_expired`) still exist but are
2018
+ * emitted on the API-key path + live-session enforcement, not on login.
2019
+ *
1961
2020
  * Node session persistence (cookie-jar across processes) is out of
1962
2021
  * scope — this method only touches the request side.
1963
2022
  */
@@ -85,6 +85,103 @@ 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;
108
+ /**
109
+ * Compress-route enum members per image mime-group, mirroring the shipped
110
+ * `availability/availability.json` `operations.compress.mime_groups.<group>.
111
+ * options.<opt>.values`. Kept as a hand table (NOT a runtime read of the ~238KB
112
+ * availability sidecar) so the enum-membership gate stays browser-safe, exactly
113
+ * like {@link IMAGE_OUTPUT_ROUTES} — and, crucially, so the gate has NO
114
+ * dependency on a contracts version that carries the enum in a compact form (a
115
+ * generated-metadata `values` field would fail open on an older published
116
+ * `@giveitsmaller/contracts`). PINNED to `availability.json` by
117
+ * `output-route-conformance.test.ts`; a contract regen that adds/changes an
118
+ * enum member fails there. Mirrored by PHP `ImageOutputRoutes::COMPRESS_OPTION_VALUES`.
119
+ *
120
+ * `image_svg`/`image_avif` carry the NARROW `metadata: ['strip','all']` (no
121
+ * `keep`) — the reason a value gate that consulted only the generic `image`
122
+ * group (`['strip','keep','all']`) let `metadata: 'keep'` reach a server 422 on
123
+ * those bases (rtkzl9gr). `output_format` is listed for a faithful projection
124
+ * mirror but is never gated here (the Output lowering owns it positionally).
125
+ */
126
+ export declare const COMPRESS_OPTION_VALUES: Readonly<Record<string, Readonly<Record<string, readonly string[]>>>>;
127
+ /**
128
+ * Whether a VALUE lies OUTSIDE the option's compress-route enum for the given
129
+ * input format — the pre-upload enum-membership gate (rtkzl9gr). Reads the hand
130
+ * {@link COMPRESS_OPTION_VALUES} table. Returns false when the option is not an
131
+ * enum on this group (no entry), so a non-enum option (e.g. integer `quality`)
132
+ * is never gated. Meaningful only on the same_format (compress) route, where
133
+ * the compress option enums definitionally apply. Membership is STRICT: a value
134
+ * whose type differs from the string enum members (e.g. numeric `420`) is
135
+ * treated as unknown rather than coerced to a match.
136
+ */
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;
90
187
  export {};
@@ -29,8 +29,18 @@
29
29
  import { compressMetadata } from '@giveitsmaller/contracts/operations';
30
30
  /** The resize option keys — input-keyed, raster-only (see module doc). */
31
31
  export const RESIZE_KEYS = ['width', 'height', 'fit'];
32
- /** Set form of {@link RESIZE_KEYS} for `string`-keyed membership tests. */
33
- const RESIZE_KEY_SET = new Set(RESIZE_KEYS);
32
+ /**
33
+ * Options whose availability follows the INPUT format's raster capability, not
34
+ * the output format — resize (`width`/`height`/`fit`) plus `auto_orient`. The
35
+ * projection lists them on every `format_change` cell (keyed by OUTPUT), so on
36
+ * a format change they must be re-gated against the INPUT's `same_format` cell:
37
+ * a raster input carries them, an SVG (vector) input does not. Before rtkzl9gr
38
+ * only the resize keys were input-gated, so `auto_orient` leaked onto the
39
+ * `svg → raster` route and was rejected server-side.
40
+ */
41
+ const INPUT_GATED_KEYS = [...RESIZE_KEYS, 'auto_orient'];
42
+ /** Set form of {@link INPUT_GATED_KEYS} for `string`-keyed membership tests. */
43
+ const INPUT_GATED_KEY_SET = new Set(INPUT_GATED_KEYS);
34
44
  /** Image area cap shared by every resizable route (projection `max_output_pixels`). */
35
45
  export const MAX_OUTPUT_PIXELS = 16_000_000;
36
46
  /**
@@ -117,33 +127,57 @@ export function resolveOutputRoute(inputToken, outputFormat) {
117
127
  const cell = IMAGE_OUTPUT_ROUTES.format_change[outToken];
118
128
  if (cell === undefined)
119
129
  return undefined;
120
- // Resize is INPUT-gated. Since v2.103.0 convert is the resize engine, so the
121
- // projection lists width/height/fit on EVERY format_change cell but an SVG
122
- // INPUT cannot be raster-resized (the convert worker rejects it). So strip the
123
- // cell's resize keys and re-add only those the INPUT's same_format cell honors:
124
- // raster inputs carry them, svg does not. The transcoder options (output_format/
125
- // quality/background) ride the cell directly.
126
- const transcoderHonored = cell.honored.filter((k) => !RESIZE_KEY_SET.has(k));
130
+ // Resize + auto_orient are INPUT-gated (see {@link INPUT_GATED_KEYS}). Since
131
+ // v2.103.0 convert is the resize engine, so the projection lists width/height/
132
+ // fit AND auto_orient on EVERY format_change cell but an SVG INPUT cannot be
133
+ // raster-resized or auto-oriented (the convert worker rejects it). So strip
134
+ // the cell's input-gated keys and re-add only those the INPUT's same_format
135
+ // cell honors: raster inputs carry them, svg does not. The transcoder options
136
+ // (output_format/quality/background/color_profile) ride the cell directly.
137
+ const transcoderHonored = cell.honored.filter((k) => !INPUT_GATED_KEY_SET.has(k));
127
138
  const inCell = IMAGE_OUTPUT_ROUTES.same_format[inputToken];
128
- const resize = inCell ? RESIZE_KEYS.filter((k) => inCell.honored.includes(k)) : [];
139
+ const inputGated = inCell ? INPUT_GATED_KEYS.filter((k) => inCell.honored.includes(k)) : [];
129
140
  return {
130
141
  route: 'format_change',
131
142
  sourceOp: 'convert',
132
143
  outputFormatWire: outToken,
133
144
  inputToken,
134
- honored: new Set([...transcoderHonored, ...resize]),
145
+ honored: new Set([...transcoderHonored, ...inputGated]),
135
146
  planned: new Set(cell.planned),
136
147
  };
137
148
  }
138
- /** Input token → its `compress.image*` mime-group name (for per-value availability lookup). */
139
- function compressGroupForToken(token) {
140
- if (token === 'jpeg')
141
- return 'image_jpeg';
142
- if (token === 'png')
143
- return 'image_png';
144
- if (token === 'avif')
145
- return 'image_avif';
146
- 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];
147
181
  }
148
182
  /**
149
183
  * Whether a specific VALUE of an option is `availability: 'planned'` for the
@@ -152,12 +186,180 @@ function compressGroupForToken(token) {
152
186
  * `compressMetadata` `per_value_availability`; same_format only (the only route
153
187
  * where value-level options like `metadata` are honored). Returns false when the
154
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.
155
207
  */
156
208
  export function isPlannedValue(inputToken, optionKey, value) {
157
- const group = compressMetadata.mime_groups[compressGroupForToken(inputToken)];
158
- const opt = group?.options[optionKey];
159
- if (opt === undefined)
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;
215
+ }
216
+ /**
217
+ * Compress-route enum members per image mime-group, mirroring the shipped
218
+ * `availability/availability.json` `operations.compress.mime_groups.<group>.
219
+ * options.<opt>.values`. Kept as a hand table (NOT a runtime read of the ~238KB
220
+ * availability sidecar) so the enum-membership gate stays browser-safe, exactly
221
+ * like {@link IMAGE_OUTPUT_ROUTES} — and, crucially, so the gate has NO
222
+ * dependency on a contracts version that carries the enum in a compact form (a
223
+ * generated-metadata `values` field would fail open on an older published
224
+ * `@giveitsmaller/contracts`). PINNED to `availability.json` by
225
+ * `output-route-conformance.test.ts`; a contract regen that adds/changes an
226
+ * enum member fails there. Mirrored by PHP `ImageOutputRoutes::COMPRESS_OPTION_VALUES`.
227
+ *
228
+ * `image_svg`/`image_avif` carry the NARROW `metadata: ['strip','all']` (no
229
+ * `keep`) — the reason a value gate that consulted only the generic `image`
230
+ * group (`['strip','keep','all']`) let `metadata: 'keep'` reach a server 422 on
231
+ * those bases (rtkzl9gr). `output_format` is listed for a faithful projection
232
+ * mirror but is never gated here (the Output lowering owns it positionally).
233
+ */
234
+ export const COMPRESS_OPTION_VALUES = {
235
+ image: { color_profile: ['keep', 'srgb', 'strip'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'keep', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'] },
236
+ image_jpeg: { chroma_subsampling: ['420', '422', '444'], color_profile: ['keep', 'srgb', 'strip'], encoding_mode: ['quality', 'target_size', 'auto_quality'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'keep', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'], quality_preset: ['best', 'good', 'fair', 'low'] },
237
+ image_png: { color_profile: ['keep', 'srgb', 'strip'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'keep', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'] },
238
+ image_avif: { color_profile: ['keep', 'srgb', 'strip'], encoding_mode: ['quality', 'target_size', 'auto_quality'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'], quality_preset: ['best', 'good', 'fair', 'low'] },
239
+ image_svg: { metadata: ['strip', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'] },
240
+ image_webp: { color_profile: ['keep', 'srgb', 'strip'], encoding_mode: ['quality', 'target_size', 'auto_quality'], fit: ['max', 'crop', 'scale'], metadata: ['strip', 'keep', 'all'], output_format: ['original', 'webp', 'auto', 'smallest'], quality_preset: ['best', 'good', 'fair', 'low'] },
241
+ };
242
+ /**
243
+ * The compress mime-group whose enum members are authoritative for an image
244
+ * token's SAME_FORMAT route — the exact `image_<token>` group when
245
+ * {@link COMPRESS_OPTION_VALUES} carries one, else the generic `image` group
246
+ * (gif/tiff).
247
+ *
248
+ * Deliberately DISTINCT from {@link compressGroupForToken} (which the planned
249
+ * gate uses). The planned gate routes webp/gif/svg/tiff through the generic
250
+ * `image` group, where cross-format `planned` markers live (e.g. `srgb`).
251
+ * Enum MEMBERSHIP is the opposite: it needs the format-specific enum, because
252
+ * `image_svg`'s `metadata` enum is the narrow `[strip, all]` while the generic
253
+ * group's is `[strip, keep, all]` — so only the specific group rejects
254
+ * `metadata: 'keep'` on SVG (and AVIF, which already maps specifically).
255
+ */
256
+ function enumGroupForToken(token) {
257
+ const specific = `image_${token}`;
258
+ return COMPRESS_OPTION_VALUES[specific] !== undefined ? specific : 'image';
259
+ }
260
+ /**
261
+ * Whether a VALUE lies OUTSIDE the option's compress-route enum for the given
262
+ * input format — the pre-upload enum-membership gate (rtkzl9gr). Reads the hand
263
+ * {@link COMPRESS_OPTION_VALUES} table. Returns false when the option is not an
264
+ * enum on this group (no entry), so a non-enum option (e.g. integer `quality`)
265
+ * is never gated. Meaningful only on the same_format (compress) route, where
266
+ * the compress option enums definitionally apply. Membership is STRICT: a value
267
+ * whose type differs from the string enum members (e.g. numeric `420`) is
268
+ * treated as unknown rather than coerced to a match.
269
+ */
270
+ export function isUnknownEnumValue(inputToken, optionKey, value) {
271
+ const members = COMPRESS_OPTION_VALUES[enumGroupForToken(inputToken)]?.[optionKey];
272
+ if (members === undefined)
160
273
  return false;
161
- const entry = opt.per_value_availability[String(value)];
162
- return entry?.availability === 'planned';
274
+ return !(typeof value === 'string' && members.includes(value));
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;
163
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. */