@adobe/spacecat-shared-project-engine-client 1.1.1 → 1.2.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
@@ -5,10 +5,7 @@ Typed integration with the Semrush **Project Engine API** (`/enterprise/projects
5
5
  - generated **TypeScript** (`src/generated/types.ts`) and **Pydantic v2** (`python/serenity_project_engine/`) types,
6
6
  - a thin **Project Engine client** (`openapi-fetch` over the generated `paths`) — IMS Bearer auth + idempotency-aware retries (see [Client](#client)),
7
7
  - a generation-time **spec-correction overlay** (`spec/overlays/corrections.yaml`) that aligns the vendored swagger with the live API,
8
- - a **Counterfact mock** for E2E tests and local dev (`npm run mock`); the stateful mock store lands in a follow-up (LLMO-5460).
9
-
10
- > **Not here yet:** the IMS auth-handler move and the stateful mock store are
11
- > tracked follow-ups (see LLMO-5461 / LLMO-5460).
8
+ - a **stateful Counterfact mock** for E2E tests and local dev (`npm run mock`, see below).
12
9
 
13
10
  This package follows the `spacecat-shared` convention: **JS + ESM**, JSDoc-typed source,
14
11
  `mocha` + `chai` + `c8` for tests, and `@adobe/eslint-config-helix` for lint. The scaffold's
@@ -56,22 +53,21 @@ access is restricted.
56
53
 
57
54
  ```
58
55
  spec/projectengine_swagger_public.yaml (vendored, Swagger 2.0)
59
-
60
- ├── Counterfact ── reads v2 directly ──► mock (no conversion) [npm run mock]
61
56
 
62
57
  └── swagger2openapi (v2 → 3.x) ──► build/openapi3.json
63
58
 
64
59
  ├── apply-overlay (corrections) ──► build/openapi3.json (in place)
65
60
 
61
+ ├── Counterfact ──► mock [npm run mock]
66
62
  ├── openapi-typescript ──► src/generated/types.ts
67
63
  └── datamodel-code-generator ──► python/serenity_project_engine/
68
64
  ```
69
65
 
70
- The v2 → 3.x conversion exists **only** to feed the type generators (`openapi-typescript`
71
- is v3-only, and 3.x yields cleaner TS/Pydantic); the overlay then corrects that converted
72
- artifact before the generators run (see [Spec corrections](#spec-corrections)). **Counterfact reads
73
- the raw v2 file directly**, so the mock path never touches the converted artifact (and so does not
74
- see the corrections a known gap the stateful-mock follow-up addresses).
66
+ The v2 → 3.x conversion + overlay feeds both the type generators and the Counterfact mock.
67
+ `npm run generate` must be run once (and re-run after any spec refresh) before `npm run mock`
68
+ `build/openapi3.json` is gitignored. The overlay is applied in place so Counterfact sees the
69
+ corrected paths (including `GET /v1/ai_models`, CR1) and the mock serves under the
70
+ `/enterprise/projects/api` base path via `--prefix`.
75
71
 
76
72
  | Command | Does |
77
73
  | --- | --- |
@@ -79,8 +75,8 @@ see the corrections — a known gap the stateful-mock follow-up addresses).
79
75
  | `npm run spec:overlay` | apply `spec/overlays/corrections.yaml` to `build/openapi3.json` in place |
80
76
  | `npm run generate:ts` | `openapi-typescript` → `src/generated/types.ts` |
81
77
  | `npm run generate:pydantic` | `datamodel-code-generator` → `python/serenity_project_engine/` package |
82
- | `npm run generate` | all of the above, in order |
83
- | `npm run mock` | Counterfact mock on `:4010`, straight off the v2 spec |
78
+ | `npm run generate` | all of the above, in order (run before `npm run mock`) |
79
+ | `npm run mock` | Counterfact mock on `:4010`, corrected OAS3 artifact + `--prefix /enterprise/projects/api` |
84
80
 
85
81
  `datamodel-code-generator` is a **Python** tool, not an npm dependency. Install it once on
86
82
  your `PATH` before running `generate:pydantic`:
@@ -108,6 +104,154 @@ Guard tests in `test/foundation.test.js` pin CR1 and CR2 against the generated s
108
104
  Semrush spec refresh that silently drops the overlay fails loudly instead of regressing the
109
105
  generated types. `test/overlay.test.js` covers the overlay applier itself.
110
106
 
107
+ ## Mock (stateful)
108
+
109
+ > **Full guide:** [`docs/mock-usage.md`](./docs/mock-usage.md) is the complete usage manual for
110
+ > humans and agents — auth, the full endpoint inventory, seeds, control routes, quota, and
111
+ > troubleshooting. This section is the summary.
112
+
113
+ `npm run mock` starts a **stateful** Counterfact server off the corrected OAS3 artifact
114
+ (`build/openapi3.json` — run `npm run generate` first). The runner serves only the modelled
115
+ handlers (`--serve`, no `generate`), so an unmodelled path **404s** — it does not fall back to a
116
+ spec-driven stub. The project spine is backed by a shared in-memory store so reads reflect prior
117
+ writes within a run; see [`docs/mock-usage.md`](./docs/mock-usage.md) §9 to add an endpoint.
118
+
119
+ ```bash
120
+ npm run mock # serves on :4010
121
+ MOCK_PORT=4032 MOCK_SEED=empty-workspace npm run mock
122
+ ```
123
+
124
+ Base URL: `http://localhost:<port>/enterprise/projects/api`.
125
+
126
+ **Auth:** every real route requires `Authorization: Bearer <token>` (any non-empty token — the
127
+ mock checks presence, not validity, like the live gateway). Missing/invalid → `401 { "detail":
128
+ "Not authenticated" }`. The `__*` control routes are exempt. See the manual §2.
129
+
130
+ | Var | Default | Purpose |
131
+ | --- | --- | --- |
132
+ | `MOCK_PORT` | `4010` | listen port |
133
+ | `MOCK_SEED` | default seed | named startup fixture; unknown values fall back to the default |
134
+ | `MOCK_SEED_FILE` | — | path to a JSON `Snapshot` to boot from; takes precedence over `MOCK_SEED` |
135
+
136
+ Stateful endpoints (backed by the store):
137
+
138
+ | Method + path | Behaviour |
139
+ | --- | --- |
140
+ | `GET/POST /v1/workspaces/{id}/projects` | list / create |
141
+ | `GET/PATCH/DELETE /v1/workspaces/{id}/projects/{project_id}` | get / update / remove (404 when missing) |
142
+ | `GET/DELETE /v1/workspaces/{id}/projects/{project_id}/ai_models` | list / batch-delete |
143
+ | `POST /v2/workspaces/{id}/projects/{project_id}/ai_models` | add (the path the real consumer uses; writes the same store collection the v1 list/delete read) |
144
+ | `POST /v2/workspaces/{id}/projects/{project_id}/aio/prompts/tagged` | create prompts grouped by tag name |
145
+ | `POST /v2/workspaces/{id}/projects/{project_id}/aio/prompts/by_tags` | list prompts (empty `tag_ids` lists all; otherwise OR-filter) |
146
+ | `DELETE /v2/workspaces/{id}/projects/{project_id}/aio/prompts` | batch-delete prompts by id |
147
+
148
+ ### Test control routes (not part of the Project Engine API)
149
+
150
+ All under the base URL, e.g. `http://localhost:<port>/enterprise/projects/api/__dump`:
151
+
152
+ | Route | Purpose |
153
+ | --- | --- |
154
+ | `POST /__reset` | restore the store to its boot seed (or the last `/__seed`) — call between E2E cases for isolation |
155
+ | `POST /__seed` | replace the store with the posted `Snapshot` and make it the new reset baseline — set the mock to exactly the state a test needs |
156
+ | `GET /__dump` | **look inside the mock DB** — returns the current store state as JSON (every `projects:{ws}` / `ai_models:{ws}:{pr}` / `prompts:{ws}:{pr}` collection and its rows) |
157
+ | `POST /__quota` | set a workspace's AI-unit allocation: `{ workspaceId, projects?, prompts? }` (mirrors a user-manager transfer; `{ projects: 0, prompts: 0 }` = empty-units child). Project create / prompt write / publish then return the disguised quota **405** when exhausted |
158
+ | `GET /__quota?workspaceId=<ws>` | read a workspace's limits + live usage |
159
+
160
+ **Model AI-unit quota (the disguised 405).** A sub-workspace with no allocation is unlimited
161
+ (default). Grant one, then the metered ops 405 when exhausted — the behaviour the consumer's
162
+ quota handling relies on:
163
+
164
+ ```bash
165
+ # grant 1 project + 2 prompts to a workspace
166
+ curl -s -XPOST .../__quota -d '{"workspaceId":"<ws>","projects":1,"prompts":2}'
167
+ # a 2nd project create, a 3rd prompt, or publishing an empty-units child now returns 405
168
+ ```
169
+
170
+ **Inspect what's inside:**
171
+
172
+ ```bash
173
+ curl -s http://localhost:4010/enterprise/projects/api/__dump | jq
174
+ ```
175
+
176
+ **Seed state that matches your DB.** `/__seed` (and a `MOCK_SEED_FILE`) take a `Snapshot`: a
177
+ plain JSON object keyed by `<resource>:<scope>` whose values are entity rows. The Project Engine
178
+ API types every id as `format: uuid`, so use the same **UUIDs** you load into Postgres
179
+ (`semrush_workspace_id` / project id) so the two sides line up, and mirror the real entity
180
+ shapes (`ProjectAIModelResponse`, `AIOPromptWithStatus`). Any caller — including the cross-repo
181
+ harness consuming the published client — can POST this JSON directly:
182
+
183
+ ```jsonc
184
+ // POST /__seed
185
+ {
186
+ "projects:a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d": [
187
+ { "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "name": "Acme", "workspace_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" }
188
+ ],
189
+ "ai_models:a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d:b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e": [
190
+ { "id": "c3d4e5f6-a7b8-4c9d-8e1f-2a3b4c5d6e7f", "model": { "id": "d4e5f6a7-b8c9-4d0e-9f1a-3b4c5d6e7f80", "key": "gpt-4o", "name": "GPT-4o" }, "prompts_count": 0 }
191
+ ],
192
+ "prompts:a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d:b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e": [
193
+ { "id": "e5f6a7b8-c9d0-4e1f-8a2b-4c5d6e7f8091", "name": "What is Acme?", "is_new": false, "tags": [] }
194
+ ]
195
+ }
196
+ ```
197
+
198
+ Rather than hand-write that JSON, use the **typed mock factories** (the
199
+ [mock factory pattern](https://dev.to/davelosert/mock-factory-pattern-in-typescript-44l9)):
200
+ each `createXMock(overrides?)` returns a spec-shaped entity typed against the generated
201
+ (overlay-corrected) component schemas, so fixtures stay in sync with the spec and `npm run
202
+ test:types` fails on drift (wrong/unknown/missing-required field). `buildSeed()` routes the
203
+ factory rows into the collection-keyed `Snapshot`. All are on the `./mock/*` subpath, so a
204
+ workspace caller (this repo's tests, or the cross-repo harness resolving spacecat-shared from
205
+ the checkout) imports them by package name:
206
+
207
+ ```js
208
+ import { buildSeed } from '@adobe/spacecat-shared-project-engine-client/mock/seeds.js';
209
+ import {
210
+ createProjectAiModelMock,
211
+ createAiModelMock,
212
+ createPromptMock,
213
+ } from '@adobe/spacecat-shared-project-engine-client/mock/factories.js';
214
+ import { randomUUID } from 'node:crypto';
215
+
216
+ const workspaceId = randomUUID();
217
+ const projectId = randomUUID();
218
+ const snapshot = buildSeed({
219
+ workspaceId,
220
+ projects: [{
221
+ id: projectId,
222
+ name: 'Acme',
223
+ aiModels: [createProjectAiModelMock({ model: createAiModelMock({ name: 'GPT-4o' }) })],
224
+ prompts: [createPromptMock({ name: 'What is Acme?' })],
225
+ }],
226
+ });
227
+ await fetch(`${baseUrl}/__seed`, { method: 'POST', body: JSON.stringify(snapshot) });
228
+ ```
229
+
230
+ The `./mock/*` subpath resolves against the **checked-out** package (the mock isn't in the
231
+ published tarball — see "Not published" below), which is the same source the harness boots the
232
+ mock from, so it adds no new coupling. A consumer that only has the *published* tarball has no
233
+ `buildSeed`; it POSTs the raw `Snapshot` JSON above instead.
234
+
235
+ `npm run test:e2e` drives the **real client** against a freshly booted mock (self-managed
236
+ lifecycle, `__reset` between cases). It is gated behind `MOCK_E2E=1` and lives outside the
237
+ default `npm test` glob, so the unit suite stays fast and keeps 100% coverage with no
238
+ live-server dependency. CI runs it as a dedicated `E2E (project-engine mock)` job.
239
+
240
+ > **How it runs:** the runner materializes the committed handlers from `mock/` into a
241
+ > gitignored `.counterfact/` tree (as `.ts`, so Counterfact's transpiler emits loadable `.cjs`)
242
+ > and launches with `--serve` so no spec stubs are appended onto the stateful handlers. The
243
+ > store, seeds, and resource ops are plain unit-tested JS in `mock/`; only the runner and
244
+ > the materialized handlers — which need a live server — are excluded from coverage.
245
+
246
+ > **Not published.** The `mock/` tree sits outside `src/` and outside the package's `files`
247
+ > allowlist, so nothing mock-related is in the published tarball — client consumers install
248
+ > only `src/` (the typed client + generated types), and `counterfact` stays a `devDependency`.
249
+ > The mock is booted from source via `npm run mock` (or `npm run test:e2e`) inside the
250
+ > monorepo / e2e harness, which is why it never needs to ship. The `./mock/*` entry in
251
+ > `exports` makes those source files importable by package name **when spacecat-shared is
252
+ > resolved from a checkout** (the workspace case); it intentionally has no effect for a
253
+ > tarball-only consumer, which never imports the mock.
254
+
111
255
  ## Committed vs generated
112
256
 
113
257
  - **Committed:** the vendored spec (`spec/projectengine_swagger_public.yaml`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-project-engine-client",
3
- "version": "1.1.1",
3
+ "version": "1.2.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",
@@ -9,7 +9,8 @@
9
9
  ".": {
10
10
  "types": "./src/index.d.ts",
11
11
  "default": "./src/index.js"
12
- }
12
+ },
13
+ "./mock/*": "./mock/*"
13
14
  },
14
15
  "files": [
15
16
  "src"
@@ -28,7 +29,9 @@
28
29
  "generate:ts": "openapi-typescript build/openapi3.json --output src/generated/types.ts",
29
30
  "generate:pydantic": "datamodel-codegen --input build/openapi3.json --input-file-type openapi --output python/serenity_project_engine/ --output-model-type pydantic_v2.BaseModel",
30
31
  "generate": "npm run spec:convert && npm run spec:overlay && npm run generate:ts && npm run generate:pydantic",
31
- "mock": "counterfact spec/projectengine_swagger_public.yaml .counterfact --port 4010"
32
+ "mock": "node mock/run.js",
33
+ "test:e2e": "MOCK_E2E=1 mocha --no-package --reporter spec --timeout 60000 test/e2e/**/*.e2e.js",
34
+ "test:types": "tsc -p tsconfig.json"
32
35
  },
33
36
  "mocha": {
34
37
  "reporter": "mocha-multi-reporters",
@@ -60,6 +63,7 @@
60
63
  "mocha-multi-reporters": "1.5.1",
61
64
  "openapi-typescript": "7.13.0",
62
65
  "sinon": "22.0.0",
63
- "swagger2openapi": "7.0.8"
66
+ "swagger2openapi": "7.0.8",
67
+ "typescript": "6.0.3"
64
68
  }
65
69
  }
package/src/client.js CHANGED
@@ -10,6 +10,8 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ // @ts-check
14
+
13
15
  import createClient from 'openapi-fetch';
14
16
  import { createRetryingFetch, toTokenGetter } from './internal.js';
15
17
 
@@ -554,68 +554,6 @@ export interface paths {
554
554
  patch?: never;
555
555
  trace?: never;
556
556
  };
557
- "/v1/workspaces/{id}/projects/{project_id}/aio/init_status": {
558
- parameters: {
559
- query?: never;
560
- header?: never;
561
- path?: never;
562
- cookie?: never;
563
- };
564
- /**
565
- * Check whether an AIO project is initialized
566
- * @description Check whether AIO project initialization has finished for workspace and project with given IDs.
567
- */
568
- get: {
569
- parameters: {
570
- query?: never;
571
- header?: never;
572
- path: {
573
- /** @description Workspace ID */
574
- id: string;
575
- /** @description Project ID */
576
- project_id: string;
577
- };
578
- cookie?: never;
579
- };
580
- requestBody?: never;
581
- responses: {
582
- /** @description OK */
583
- 200: {
584
- headers: {
585
- [name: string]: unknown;
586
- };
587
- content: {
588
- "application/json": components["schemas"]["model.AIOProjectInitializedResponse"];
589
- };
590
- };
591
- /** @description Bad Request */
592
- 400: {
593
- headers: {
594
- [name: string]: unknown;
595
- };
596
- content: {
597
- "application/json": components["schemas"]["http_server.BasicResponse"];
598
- };
599
- };
600
- /** @description Internal Server Error */
601
- 500: {
602
- headers: {
603
- [name: string]: unknown;
604
- };
605
- content: {
606
- "application/json": components["schemas"]["http_server.BasicResponse"];
607
- };
608
- };
609
- };
610
- };
611
- put?: never;
612
- post?: never;
613
- delete?: never;
614
- options?: never;
615
- head?: never;
616
- patch?: never;
617
- trace?: never;
618
- };
619
557
  "/v1/workspaces/{id}/projects/{project_id}/apply_interactive_ignore_issue_rules/{crawl_configuration_id}": {
620
558
  parameters: {
621
559
  query?: never;
@@ -1644,6 +1582,26 @@ export interface paths {
1644
1582
  patch?: never;
1645
1583
  trace?: never;
1646
1584
  };
1585
+ "/v2/workspaces/{id}/projects/{project_id}/aio/init_status": {
1586
+ parameters: {
1587
+ query?: never;
1588
+ header?: never;
1589
+ path?: never;
1590
+ cookie?: never;
1591
+ };
1592
+ /**
1593
+ * AIO project initialization status
1594
+ * @description Check whether AIO project initialization has finished for the workspace and project.
1595
+ */
1596
+ get: operations["aio-get-project-init-status-v2"];
1597
+ put?: never;
1598
+ post?: never;
1599
+ delete?: never;
1600
+ options?: never;
1601
+ head?: never;
1602
+ patch?: never;
1603
+ trace?: never;
1604
+ };
1647
1605
  }
1648
1606
  export type webhooks = Record<string, never>;
1649
1607
  export interface components {
@@ -1661,7 +1619,7 @@ export interface components {
1661
1619
  };
1662
1620
  "model.AIModelResponse": {
1663
1621
  icon?: string;
1664
- id?: string;
1622
+ id: string;
1665
1623
  key?: string;
1666
1624
  name?: string;
1667
1625
  };
@@ -1678,12 +1636,14 @@ export interface components {
1678
1636
  color?: string;
1679
1637
  domain?: string;
1680
1638
  favorite?: boolean;
1681
- id?: string;
1639
+ id: string;
1682
1640
  main_brand?: boolean;
1683
1641
  product_names?: string[];
1684
1642
  products_count?: number;
1685
1643
  project_id?: string;
1686
1644
  rejected_brand_aliases?: string[];
1645
+ primary_url?: string;
1646
+ root_domain?: string;
1687
1647
  };
1688
1648
  "model.AIOProduct": {
1689
1649
  aliases?: string[];
@@ -1700,10 +1660,10 @@ export interface components {
1700
1660
  initialized?: boolean;
1701
1661
  };
1702
1662
  "model.AIOPromptWithStatus": {
1703
- id?: string;
1663
+ id: string;
1704
1664
  is_new?: boolean;
1705
- name?: string;
1706
- tags?: components["schemas"]["model.AIOTag"][];
1665
+ name: string;
1666
+ tags: components["schemas"]["model.AIOTag"][];
1707
1667
  };
1708
1668
  "model.AIOPromptsListRequest": {
1709
1669
  limit?: number;
@@ -1754,6 +1714,7 @@ export interface components {
1754
1714
  products_count?: number;
1755
1715
  prompts_count?: number;
1756
1716
  segments_count?: number;
1717
+ primary_url?: string;
1757
1718
  };
1758
1719
  "model.AdvancedCrawlingConsentRequest": {
1759
1720
  accepted: boolean;
@@ -1787,7 +1748,7 @@ export interface components {
1787
1748
  "model.BrandURL": {
1788
1749
  benchmark_id?: string;
1789
1750
  created_at?: string;
1790
- id?: string;
1751
+ id: string;
1791
1752
  project_id?: string;
1792
1753
  type?: string;
1793
1754
  updated_at?: string;
@@ -2145,9 +2106,9 @@ export interface components {
2145
2106
  total?: number;
2146
2107
  };
2147
2108
  "model.ProjectAIModelResponse": {
2148
- id?: string;
2149
- model?: components["schemas"]["model.AIModelResponse"];
2150
- prompts_count?: number;
2109
+ id: string;
2110
+ model: components["schemas"]["model.AIModelResponse"];
2111
+ prompts_count: number;
2151
2112
  };
2152
2113
  "model.ProjectListResponse": {
2153
2114
  items?: components["schemas"]["model.ProjectResponse"][];
@@ -2193,10 +2154,10 @@ export interface components {
2193
2154
  domain?: string;
2194
2155
  draft_id?: string;
2195
2156
  favourite?: boolean;
2196
- id?: string;
2157
+ id: string;
2197
2158
  is_draft?: boolean;
2198
2159
  live_id?: string;
2199
- name?: string;
2160
+ name: string;
2200
2161
  /** @description enum: draft, publishing, initial_publish_failed, live, live_with_unpublished_updates */
2201
2162
  publish_status?: string;
2202
2163
  published_at?: string;
@@ -6561,8 +6522,8 @@ export interface operations {
6561
6522
  };
6562
6523
  requestBody: components["requestBodies"]["model.CreateProjectAIModelRequest"];
6563
6524
  responses: {
6564
- /** @description OK */
6565
- 200: {
6525
+ /** @description Created */
6526
+ 201: {
6566
6527
  headers: {
6567
6528
  [name: string]: unknown;
6568
6529
  };
@@ -8057,7 +8018,7 @@ export interface operations {
8057
8018
  [name: string]: unknown;
8058
8019
  };
8059
8020
  content: {
8060
- "application/json": components["schemas"]["model.TreeNodeResponse"];
8021
+ "application/json": components["schemas"]["model.TreeNodeResponse"][];
8061
8022
  };
8062
8023
  };
8063
8024
  /** @description Unauthorized */
@@ -8370,4 +8331,36 @@ export interface operations {
8370
8331
  };
8371
8332
  };
8372
8333
  };
8334
+ "aio-get-project-init-status-v2": {
8335
+ parameters: {
8336
+ query?: never;
8337
+ header?: never;
8338
+ path: {
8339
+ id: string;
8340
+ project_id: string;
8341
+ };
8342
+ cookie?: never;
8343
+ };
8344
+ requestBody?: never;
8345
+ responses: {
8346
+ /** @description OK */
8347
+ 200: {
8348
+ headers: {
8349
+ [name: string]: unknown;
8350
+ };
8351
+ content: {
8352
+ "application/json": components["schemas"]["model.AIOProjectInitializedResponse"];
8353
+ };
8354
+ };
8355
+ /** @description Unauthorized */
8356
+ 401: {
8357
+ headers: {
8358
+ [name: string]: unknown;
8359
+ };
8360
+ content: {
8361
+ "application/json": components["schemas"]["http_server.BasicResponse"];
8362
+ };
8363
+ };
8364
+ };
8365
+ };
8373
8366
  }
package/src/index.js CHANGED
@@ -10,4 +10,6 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ // @ts-check
14
+
13
15
  export { createSerenityProjectEngineApiClient } from './client.js';
package/src/internal.js CHANGED
@@ -10,6 +10,8 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ // @ts-check
14
+
13
15
  /**
14
16
  * Framework-agnostic building blocks for the Project Engine client.
15
17
  * Deliberately free of `openapi-fetch` and generated-type imports so they can be
@@ -25,7 +27,7 @@ const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS']);
25
27
 
26
28
  /**
27
29
  * Resolves the HTTP method for a fetch call the same way the platform does.
28
- * @param {RequestInfo | URL} input
30
+ * @param {Request | string | URL} input the `fetch` input (a Request, URL, or URL string)
29
31
  * @param {RequestInit} [init]
30
32
  * @returns {string} the upper-cased method
31
33
  */
@@ -122,14 +124,23 @@ export function nextRetryDelayMs(completedAttempt, baseDelayMs, response) {
122
124
  return Math.min(delay, MAX_RETRY_DELAY_MS);
123
125
  }
124
126
 
127
+ /**
128
+ * @typedef {object} RetryInfo
129
+ * @property {number} attempt the 1-based number of the retry about to be made
130
+ * @property {number} delayMs the wait before this retry
131
+ * @property {string} method the HTTP method
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
134
+ */
135
+
125
136
  /**
126
137
  * Invokes a best-effort {@link OnRetry} hook, swallowing both synchronous throws and asynchronous
127
138
  * rejections so a broken observability callback can never break the retry loop or the request.
128
139
  * The hook fires fire-and-forget (never awaited), so it cannot delay a retry. Its own failures are
129
140
  * deliberately silent (no signal is emitted) — surfacing them would itself need an observability
130
141
  * channel; observability must never affect the request outcome.
131
- * @param {OnRetry} [onRetry]
132
- * @param {object} info
142
+ * @param {OnRetry | undefined} onRetry
143
+ * @param {RetryInfo} info
133
144
  */
134
145
  function notifyRetry(onRetry, info) {
135
146
  if (!onRetry) {
@@ -139,7 +150,7 @@ function notifyRetry(onRetry, info) {
139
150
  const result = onRetry(info);
140
151
  // An async onRetry returns a promise; sink its rejection here so a rejecting hook can't escape
141
152
  // as an unhandled promise rejection (which crashes the process in Node 18+). Not awaited.
142
- if (result && typeof result.catch === 'function') {
153
+ if (result instanceof Promise) {
143
154
  result.catch(() => {});
144
155
  }
145
156
  } catch {
@@ -149,12 +160,7 @@ function notifyRetry(onRetry, info) {
149
160
 
150
161
  /**
151
162
  * @callback OnRetry
152
- * @param {object} info
153
- * @param {number} info.attempt the 1-based number of the retry about to be made
154
- * @param {number} info.delayMs the wait before this retry
155
- * @param {string} info.method the HTTP method
156
- * @param {number} [info.status] the retryable response status that triggered the retry, if any
157
- * @param {Error} [info.error] the network error that triggered the retry, if any
163
+ * @param {RetryInfo} info
158
164
  * @returns {void | Promise<void>} may be async; the return is not awaited (fire-and-forget)
159
165
  */
160
166
 
@@ -197,7 +203,13 @@ export function createRetryingFetch(baseFetch, maxRetries, baseDelayMs, onRetry)
197
203
  for (let attempt = 0; attempt <= attempts; attempt += 1) {
198
204
  if (attempt > 0) {
199
205
  notifyRetry(onRetry, {
200
- attempt, delayMs: nextDelayMs, method, status: lastResponse?.status, error: lastError,
206
+ attempt,
207
+ delayMs: nextDelayMs,
208
+ method,
209
+ status: lastResponse?.status,
210
+ // lastError is a caught throw (unknown); pass it only when it's a real Error. The
211
+ // original value is still rethrown unchanged below if retries are exhausted.
212
+ error: lastError instanceof Error ? lastError : undefined,
201
213
  });
202
214
  // eslint-disable-next-line no-await-in-loop
203
215
  await sleep(nextDelayMs);