@adobe/spacecat-shared-project-engine-client 1.10.1 → 1.12.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
@@ -34,6 +34,10 @@ const { data, error } = await client.GET('/v1/countries');
34
34
  - **Retries:** `429` is retried for any method; `5xx`/network errors only for idempotent methods
35
35
  (so a POST is never replayed). Backoff is exponential with jitter, honours `Retry-After`, and is
36
36
  capped at 20s/attempt. Pass `onRetry` to observe the loop.
37
+ - **Timeouts:** pass `requestTimeoutMs` to bound each attempt — a stalled attempt is aborted via
38
+ `AbortSignal.timeout` (a per-attempt deadline, combined with any caller `signal`, never
39
+ replacing it) and, for idempotent methods, retried. Unset (default) ⇒ no client-imposed deadline,
40
+ so a hung socket blocks until the platform's own limit; set this to bound it.
37
41
  - **Shape:** this is a thin factory function rather than the `CLAUDE.md` "class + factory" client
38
42
  pattern — the wrapper has no per-instance state or behaviour beyond what `openapi-fetch` already
39
43
  provides, so a class would add ceremony without value. The typed surface IS the `openapi-fetch`
@@ -46,8 +50,18 @@ version control. Semrush only provides the file (no endpoint access in the near
46
50
  it's **Swagger 2.0** (no v3/v3.1 on offer). The vendored file is **never edited**; where it
47
51
  diverges from the live API, a generation-time overlay corrects the converted artifact instead (see
48
52
  [Spec corrections](#spec-corrections)). Refresh is **manual**: drop in the newer file, re-run
49
- `npm run generate`, and review the diff. There is no automated drift detection while endpoint
50
- access is restricted.
53
+ `npm run generate`, and review the diff. A committed checksum lock
54
+ (`spec/projectengine_swagger_public.yaml.sha256`) is verified by `npm run spec:verify` (wired into
55
+ `pretest`, so CI enforces it): any re-vendor of the spec fails the build until the hash is
56
+ explicitly regenerated with `npm run spec:lock` and reviewed. A true live-drift contract test
57
+ against Semrush remains blocked on endpoint access.
58
+
59
+ Both scripts use a **package-root-relative** path and assume the working directory is this
60
+ package root — always invoke them via `npm run spec:verify` / `npm run spec:lock`, which npm
61
+ runs from the package root, rather than calling `shasum -c` by hand from the monorepo root or a
62
+ subdirectory (that yields a confusing "no such file" error). They also assume `shasum` (Perl,
63
+ present on macOS and the `ubuntu-latest` CI runner); minimal images such as Alpine ship
64
+ `sha256sum` instead and would need the scripts adjusted.
51
65
 
52
66
  ## Pipeline
53
67
 
@@ -144,6 +158,7 @@ Stateful endpoints (backed by the store):
144
158
  | `POST /v2/workspaces/{id}/projects/{project_id}/aio/prompts/tagged` | create prompts grouped by tag name |
145
159
  | `POST /v2/workspaces/{id}/projects/{project_id}/aio/prompts/by_tags` | list prompts (empty `tag_ids` lists all; otherwise OR-filter) |
146
160
  | `DELETE /v2/workspaces/{id}/projects/{project_id}/aio/prompts` | batch-delete prompts by id |
161
+ | `POST /v2/workspaces/{id}/projects/{project_id}/aio/prompts/{prompt_id}/rename` | in-place text edit (id stable; `409` on a sibling-text collision; `is_updated` mirrors the live layer) |
147
162
 
148
163
  ### Test control routes (not part of the Project Engine API)
149
164
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-project-engine-client",
3
- "version": "1.10.1",
3
+ "version": "1.12.0",
4
4
  "description": "Shared modules of the Spacecat Services - Semrush Project Engine client and generated types",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -20,12 +20,15 @@
20
20
  "npm": ">=10.9.0 <12.0.0"
21
21
  },
22
22
  "scripts": {
23
+ "pretest": "npm run spec:verify",
23
24
  "test": "c8 mocha",
24
25
  "lint": "eslint .",
25
26
  "lint:fix": "eslint --fix .",
26
27
  "clean": "rm -rf node_modules build .counterfact",
27
28
  "spec:convert": "mkdir -p build && swagger2openapi spec/projectengine_swagger_public.yaml --patch --outfile build/openapi3.json",
28
29
  "spec:overlay": "node scripts/apply-overlay.mjs",
30
+ "spec:verify": "shasum -a 256 -c spec/projectengine_swagger_public.yaml.sha256",
31
+ "spec:lock": "shasum -a 256 spec/projectengine_swagger_public.yaml > spec/projectengine_swagger_public.yaml.sha256",
29
32
  "generate:ts": "openapi-typescript build/openapi3.json --output src/generated/types.ts",
30
33
  "generate:pydantic": "datamodel-codegen --input build/openapi3.json --input-file-type openapi --output python/serenity_project_engine/ --output-model-type pydantic_v2.BaseModel",
31
34
  "generate": "npm run spec:convert && npm run spec:overlay && npm run generate:ts && npm run generate:pydantic",
package/src/client.js CHANGED
@@ -44,6 +44,11 @@ import { createRetryingFetch, toTokenGetter } from './internal.js';
44
44
  * retry sleep (`{ attempt, delayMs, method, status?, error? }`), for logging/metrics. A retry
45
45
  * loop is otherwise silent — an operator can't tell "slow upstream" from "stuck in backoff". A
46
46
  * throwing or rejecting hook is swallowed and never affects the request.
47
+ * @property {number} [requestTimeoutMs] Per-attempt request deadline in ms. When set (> 0), each
48
+ * fetch attempt is aborted via `AbortSignal.timeout` after this many ms and — for an idempotent
49
+ * method — retried under the retry budget; a caller-supplied `signal` is still honoured
50
+ * (combined, not replaced). Unset (the default) ⇒ no client-imposed deadline, so a hung socket
51
+ * blocks until the platform's own limit; set this to bound it.
47
52
  * @property {typeof globalThis.fetch} [fetch] Injectable fetch (tests, custom agents).
48
53
  * Defaults to the global fetch.
49
54
  */
@@ -120,12 +125,37 @@ export function createSerenityProjectEngineApiClient(options) {
120
125
  maxRetries = 2,
121
126
  retryBaseDelayMs = 200,
122
127
  onRetry,
128
+ requestTimeoutMs,
123
129
  fetch: injectedFetch = globalThis.fetch,
124
130
  } = options;
125
131
 
132
+ // Fail fast on a misconfigured timeout rather than silently disabling it: a NaN/negative value
133
+ // would no-op in withDeadline (leaving the caller unprotected), and Infinity would reach
134
+ // AbortSignal.timeout. Mirrors the defensive toTokenGetter/resolveBaseUrl guards below.
135
+ if (
136
+ requestTimeoutMs !== undefined
137
+ && (typeof requestTimeoutMs !== 'number'
138
+ || !Number.isFinite(requestTimeoutMs)
139
+ || requestTimeoutMs <= 0)
140
+ ) {
141
+ throw new Error(
142
+ // Report numbers verbatim so NaN/Infinity read as themselves (JSON.stringify would render
143
+ // both as "null"); stringify other types so a bad string is visibly quoted.
144
+ `Project Engine client: requestTimeoutMs must be a positive finite number of ms, got ${
145
+ typeof requestTimeoutMs === 'number' ? requestTimeoutMs : JSON.stringify(requestTimeoutMs)
146
+ }`,
147
+ );
148
+ }
149
+
126
150
  const client = createClient({
127
151
  baseUrl: resolveBaseUrl(baseUrl),
128
- fetch: createRetryingFetch(injectedFetch, maxRetries, retryBaseDelayMs, onRetry),
152
+ fetch: createRetryingFetch(
153
+ injectedFetch,
154
+ maxRetries,
155
+ retryBaseDelayMs,
156
+ onRetry,
157
+ requestTimeoutMs,
158
+ ),
129
159
  });
130
160
  // Auth runs as openapi-fetch middleware, so the token getter resolves once per logical request
131
161
  // and that token is reused across the request's retries (the retry layer clones the same Request
@@ -2289,7 +2289,7 @@ export interface components {
2289
2289
  replace?: boolean;
2290
2290
  };
2291
2291
  "model.RenamePromptRequest": {
2292
- new_name?: string;
2292
+ new_name?: string | null;
2293
2293
  };
2294
2294
  "model.RenamePromptResponse": {
2295
2295
  id?: string;
@@ -7723,6 +7723,15 @@ export interface operations {
7723
7723
  "application/json": components["schemas"]["http_server.BasicResponse"];
7724
7724
  };
7725
7725
  };
7726
+ /** @description Conflict */
7727
+ 409: {
7728
+ headers: {
7729
+ [name: string]: unknown;
7730
+ };
7731
+ content: {
7732
+ "application/json": components["schemas"]["http_server.BasicResponse"];
7733
+ };
7734
+ };
7726
7735
  /** @description Internal Server Error */
7727
7736
  500: {
7728
7737
  headers: {
package/src/index.d.ts CHANGED
@@ -52,6 +52,13 @@ export interface SerenityProjectEngineApiClientOptions {
52
52
  status?: number;
53
53
  error?: Error;
54
54
  }) => void | Promise<void>;
55
+ /**
56
+ * Per-attempt request deadline in ms. When set (> 0), each fetch attempt is aborted via
57
+ * `AbortSignal.timeout` after this many ms (and retried for idempotent methods under the retry
58
+ * budget); any caller-supplied `signal` is combined with it, never replaced. Unset ⇒ no
59
+ * client-imposed deadline.
60
+ */
61
+ requestTimeoutMs?: number;
55
62
  /** Injectable fetch (tests, custom agents). Defaults to the global fetch. */
56
63
  fetch?: typeof globalThis.fetch;
57
64
  }
package/src/internal.js CHANGED
@@ -130,7 +130,8 @@ export function nextRetryDelayMs(completedAttempt, baseDelayMs, response) {
130
130
  * @property {number} delayMs the wait before this retry
131
131
  * @property {string} method the HTTP method
132
132
  * @property {number} [status] the retryable response status that triggered the retry, if any
133
- * @property {Error} [error] the network error that triggered the retry, if any
133
+ * @property {Error} [error] the error that triggered the retry, if any — a network error, or a
134
+ * per-attempt `AbortSignal.timeout` `TimeoutError` (both are `instanceof Error`)
134
135
  */
135
136
 
136
137
  /**
@@ -164,9 +165,32 @@ function notifyRetry(onRetry, info) {
164
165
  * @returns {void | Promise<void>} may be async; the return is not awaited (fire-and-forget)
165
166
  */
166
167
 
168
+ /**
169
+ * Builds the per-attempt fetch `init` for {@link createRetryingFetch}. When `requestTimeoutMs`
170
+ * is a positive number, each attempt gets a FRESH `AbortSignal.timeout(requestTimeoutMs)` (the
171
+ * retry layer calls the underlying fetch once per attempt, so this is a per-attempt deadline, not
172
+ * a single budget spanning the whole retry loop) — combined with, never replacing, any
173
+ * caller-supplied signal via `AbortSignal.any`, so a caller abort and the deadline each still
174
+ * cancel the request. With no timeout configured the caller's `init` is returned untouched, so a
175
+ * caller signal continues to flow through natively.
176
+ * @param {RequestInit} [init]
177
+ * @param {AbortSignal} [callerSignal]
178
+ * @param {number} [requestTimeoutMs]
179
+ * @returns {RequestInit | undefined}
180
+ */
181
+ export function withDeadline(init, callerSignal, requestTimeoutMs) {
182
+ if (!requestTimeoutMs || requestTimeoutMs <= 0) {
183
+ return init;
184
+ }
185
+ const timeoutSignal = AbortSignal.timeout(requestTimeoutMs);
186
+ const signal = callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;
187
+ return { ...init, signal };
188
+ }
189
+
167
190
  /**
168
191
  * Wraps a fetch with bounded exponential-backoff retries. Retryable statuses follow
169
- * {@link isRetryableStatus}; thrown network errors are retried only for idempotent methods.
192
+ * {@link isRetryableStatus}; thrown errors (a network error, or a per-attempt timeout) are
193
+ * retried only for idempotent methods.
170
194
  * The wait between attempts is {@link nextRetryDelayMs} — jittered exponential backoff that also
171
195
  * honours a `Retry-After` header. After exhausting retries it returns the last retryable response
172
196
  * (so the caller still sees e.g. the final 503) or rethrows the last network error.
@@ -180,9 +204,12 @@ function notifyRetry(onRetry, info) {
180
204
  * @param {number} maxRetries
181
205
  * @param {number} baseDelayMs
182
206
  * @param {OnRetry} [onRetry] optional best-effort retry-observability hook
207
+ * @param {number} [requestTimeoutMs] optional per-attempt deadline in ms; when > 0 each attempt is
208
+ * aborted via `AbortSignal.timeout` (combined with any caller signal) and, for idempotent
209
+ * methods, retried under the retry budget. Unset ⇒ no client-imposed deadline.
183
210
  * @returns {typeof globalThis.fetch}
184
211
  */
185
- export function createRetryingFetch(baseFetch, maxRetries, baseDelayMs, onRetry) {
212
+ export function createRetryingFetch(baseFetch, maxRetries, baseDelayMs, onRetry, requestTimeoutMs) {
186
213
  return async function retryingFetch(input, init) {
187
214
  const method = methodOf(input, init);
188
215
  // Floor at 0: a negative maxRetries would skip the loop entirely, leaving both lastResponse
@@ -196,6 +223,9 @@ export function createRetryingFetch(baseFetch, maxRetries, baseDelayMs, onRetry)
196
223
  // token is resolved per request, not per attempt; with the ceiling above the whole loop is
197
224
  // bounded well under an IMS token's lifetime, so mid-loop expiry is a non-issue.
198
225
  const forAttempt = () => (input instanceof Request ? input.clone() : input);
226
+ // Resolve any caller-supplied AbortSignal once. openapi-fetch calls us with a Request whose
227
+ // own `.signal` reflects a caller `signal` option; a bare-URL fetch may carry it on `init`.
228
+ const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
199
229
  let lastResponse;
200
230
  let lastError;
201
231
  let nextDelayMs = 0;
@@ -215,8 +245,9 @@ export function createRetryingFetch(baseFetch, maxRetries, baseDelayMs, onRetry)
215
245
  await sleep(nextDelayMs);
216
246
  }
217
247
  try {
248
+ const attemptInit = withDeadline(init, callerSignal, requestTimeoutMs);
218
249
  // eslint-disable-next-line no-await-in-loop
219
- const response = await baseFetch(forAttempt(), init);
250
+ const response = await baseFetch(forAttempt(), attemptInit);
220
251
  if (!isRetryableStatus(method, response.status)) {
221
252
  return response;
222
253
  }