@camunda8/orchestration-cluster-api 1.0.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 ADDED
@@ -0,0 +1,1034 @@
1
+ <!-- Greenfield public README for the Camunda 8 Orchestration Cluster TypeScript SDK -->
2
+
3
+ # Camunda 8 Orchestration Cluster TypeScript SDK (Pre‑release)
4
+
5
+ Type‑safe, promise‑based client for the Camunda 8 Orchestration Cluster REST API.
6
+
7
+ ## Highlights
8
+
9
+ - Strong TypeScript models (requests, responses, discriminated unions)
10
+ - Branded key types to prevent mixing IDs at compile time
11
+ - Optional request/response schema validation (Zod) via a single env variable
12
+ - OAuth2 client‑credentials & Basic auth (token cache, early refresh, jittered retry, singleflight)
13
+ - Optional mTLS (Node) with inline or \*\_PATH environment variables
14
+ - Cancelable promises for all operations
15
+ - Eventual consistency helper for polling endpoints
16
+ - Immutable, deep‑frozen configuration accessible through a factory‑created client instance
17
+ - Automatic body-level tenantId defaulting: if a request body supports an optional tenantId and you omit it, the SDK fills it from CAMUNDA_DEFAULT_TENANT_ID (path params are never auto-filled)
18
+ - Automatic transient HTTP retry (429, 503, network) with exponential backoff + full jitter (configurable via CAMUNDA*SDK_HTTP_RETRY*\*). Non-retryable 500s fail fast. Pluggable strategy surface (default uses p-retry when available, internal fallback otherwise).
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install @camunda8/orchestration-cluster-api
24
+ ```
25
+
26
+ Runtime support:
27
+
28
+ - Node 20+ (native fetch & File; Node 18 needs global File polyfill)
29
+ - Modern browsers (Chromium, Firefox, Safari) – global `fetch` & `File` available
30
+
31
+ For older Node versions supply a fetch ponyfill AND a `File` shim (or upgrade). For legacy browsers, add a fetch polyfill (e.g. `whatwg-fetch`).
32
+
33
+ ## Quick Start (Zero‑Config – Recommended)
34
+
35
+ Keep configuration out of application code. Let the factory read `CAMUNDA_*` variables from the environment (12‑factor style). This makes rotation, secret management, and environment promotion safer & simpler.
36
+
37
+ ```ts
38
+ import createCamundaClient from '@camunda8/orchestration-cluster-api';
39
+
40
+ // Zero‑config construction: reads CAMUNDA_* from process.env once.
41
+ const camunda = createCamundaClient();
42
+
43
+ const topology = await camunda.getTopology();
44
+ console.log('Brokers:', topology.brokers?.length ?? 0);
45
+ ```
46
+
47
+ Typical `.env` (example):
48
+
49
+ ```bash
50
+ CAMUNDA_REST_ADDRESS=https://cluster.example # SDK will use https://cluster.example/v2/... unless /v2 already present
51
+ CAMUNDA_AUTH_STRATEGY=OAUTH
52
+ CAMUNDA_CLIENT_ID=***
53
+ CAMUNDA_CLIENT_SECRET=***
54
+ CAMUNDA_DEFAULT_TENANT_ID=<default> # optional: override default tenant resolution
55
+ CAMUNDA_SDK_HTTP_RETRY_MAX_ATTEMPTS=4 # optional: total attempts (initial + 3 retries)
56
+ CAMUNDA_SDK_HTTP_RETRY_BASE_DELAY_MS=100 # optional: base backoff (ms)
57
+ CAMUNDA_SDK_HTTP_RETRY_MAX_DELAY_MS=2000 # optional: cap (ms)
58
+ ```
59
+
60
+ > Prefer environment / secret manager injection over hard‑coding values in source. Treat the SDK like a leaf dependency: construct once near process start, pass the instance where needed.
61
+
62
+ > **Why zero‑config?**
63
+ >
64
+ > - Separation of concerns: business code depends on an interface, not on secret/constants wiring.
65
+ > - 12‑Factor alignment: config lives in the environment → simpler promotion (dev → staging → prod).
66
+ > - Secret rotation & incident response: rotate credentials without a code change or redeploy of application containers built with baked‑in values.
67
+ > - Immutable start: single hydration pass prevents drift / mid‑request mutations.
68
+ > - Test ergonomics: swap an `.env.test` (or injected vars) without touching source; create multiple clients for multi‑tenant tests.
69
+ > - Security review: fewer code paths handling secrets; scanners & vault tooling work at the boundary.
70
+ > - Deploy portability: same artifact runs everywhere; only the environment differs.
71
+ > - Observability clarity: configuration diffing is an ops concern, not an application code diff.
72
+
73
+ ### Advanced: Programmatic Overrides
74
+
75
+ Use only when you must supply or mutate configuration dynamically (e.g. multi‑tenant routing, tests, ephemeral preview environments) or in the browser. Keys mirror their `CAMUNDA_*` env names.
76
+
77
+ ```ts
78
+ const camunda = createCamundaClient({
79
+ config: {
80
+ CAMUNDA_REST_ADDRESS: 'https://cluster.example',
81
+ CAMUNDA_AUTH_STRATEGY: 'BASIC',
82
+ CAMUNDA_BASIC_AUTH_USERNAME: 'alice',
83
+ CAMUNDA_BASIC_AUTH_PASSWORD: 'secret',
84
+ },
85
+ });
86
+ ```
87
+
88
+ ### Advanced: Custom Fetch Implementation
89
+
90
+ Inject a custom `fetch` to add tracing, mock responses, instrumentation, circuit breakers, etc.
91
+
92
+ ```ts
93
+ const camunda = createCamundaClient({
94
+ fetch: (input, init) => {
95
+ // inspect / modify request here
96
+ return fetch(input, init);
97
+ },
98
+ });
99
+ ```
100
+
101
+ ### Reconfiguration At Runtime (Rare)
102
+
103
+ You can call `client.configure({ config: { ... } })` to re‑hydrate. The exposed `client.getConfig()` stays `Readonly` and deep‑frozen. Prefer creating a new client instead of mutating a shared one in long‑lived services.
104
+
105
+ ## Validation
106
+
107
+ Controlled by `CAMUNDA_SDK_VALIDATION` (or `config` override). Grammar:
108
+
109
+ ```
110
+ none | warn | strict | req:<mode>[,res:<mode>] | res:<mode>[,req:<mode>]
111
+ <mode> = none|warn|strict
112
+ ```
113
+
114
+ Examples:
115
+
116
+ ```bash
117
+ CAMUNDA_SDK_VALIDATION=warn # warn on both
118
+ CAMUNDA_SDK_VALIDATION=req:strict,res:warn
119
+ CAMUNDA_SDK_VALIDATION=none
120
+ ```
121
+
122
+ Behavior:
123
+
124
+ ## Advanced HTTP Retry: Cockatiel Adapter (Optional)
125
+
126
+ The SDK includes built‑in transient HTTP retry (429, 503, network errors) using a p‑retry based engine plus a fallback implementation. For advanced resilience patterns (circuit breakers, timeouts, custom classification, combining policies) you can integrate [cockatiel](https://github.com/connor4312/cockatiel).
127
+
128
+ ### When To Use Cockatiel
129
+
130
+ - You need different retry policies per operation (e.g. idempotent GET vs mutating POST)
131
+ - You want circuit breaking, hedging, timeout, or bulkhead controls
132
+ - You want to add custom classification (e.g. retry certain 5xx only on safe verbs)
133
+
134
+ ### Disable Built‑In HTTP Retries
135
+
136
+ Set `CAMUNDA_SDK_HTTP_RETRY_MAX_ATTEMPTS=1` so the SDK does only the initial attempt; then wrap operations with cockatiel.
137
+
138
+ ### Minimal Example (Single Operation)
139
+
140
+ ```ts
141
+ import { createCamundaClient } from '@camunda8/orchestration-cluster-api';
142
+ import { retry, ExponentialBackoff, handleAll } from 'cockatiel';
143
+
144
+ const client = createCamundaClient({
145
+ config: {
146
+ CAMUNDA_REST_ADDRESS: 'https://cluster.example',
147
+ CAMUNDA_AUTH_STRATEGY: 'NONE',
148
+ CAMUNDA_SDK_HTTP_RETRY_MAX_ATTEMPTS: 1, // disable SDK automatic retries
149
+ } as any,
150
+ });
151
+
152
+ // Policy: up to 5 attempts total (1 + 4 retries) with exponential backoff & jitter
153
+ const policy = retry(handleAll, {
154
+ maxAttempts: 5,
155
+ backoff: new ExponentialBackoff({ initialDelay: 100, maxDelay: 2000, jitter: true }),
156
+ });
157
+
158
+ // Wrap getTopology
159
+ const origGetTopology = client.getTopology.bind(client);
160
+ client.getTopology = (() => policy.execute(() => origGetTopology())) as any;
161
+
162
+ const topo = await client.getTopology();
163
+ console.log(topo.brokers?.length);
164
+ ```
165
+
166
+ ### Bulk Wrapping All Operations
167
+
168
+ ```ts
169
+ import { createCamundaClient } from '@camunda8/orchestration-cluster-api';
170
+ import { retry, ExponentialBackoff, handleAll } from 'cockatiel';
171
+
172
+ const client = createCamundaClient({
173
+ config: {
174
+ CAMUNDA_REST_ADDRESS: 'https://cluster.example',
175
+ CAMUNDA_AUTH_STRATEGY: 'OAUTH',
176
+ CAMUNDA_CLIENT_ID: process.env.CAMUNDA_CLIENT_ID,
177
+ CAMUNDA_CLIENT_SECRET: process.env.CAMUNDA_CLIENT_SECRET,
178
+ CAMUNDA_OAUTH_URL: process.env.CAMUNDA_OAUTH_URL,
179
+ CAMUNDA_TOKEN_AUDIENCE: 'zeebe.camunda.io',
180
+ CAMUNDA_SDK_HTTP_RETRY_MAX_ATTEMPTS: 1,
181
+ } as any,
182
+ });
183
+
184
+ const retryPolicy = retry(handleAll, {
185
+ maxAttempts: 4,
186
+ backoff: new ExponentialBackoff({ initialDelay: 150, maxDelay: 2500, jitter: true }),
187
+ });
188
+
189
+ const skip = new Set([
190
+ 'logger',
191
+ 'configure',
192
+ 'getConfig',
193
+ 'withCorrelation',
194
+ 'deployResourcesFromFiles',
195
+ ]);
196
+
197
+ for (const key of Object.keys(client)) {
198
+ const val: any = (client as any)[key];
199
+ if (typeof val === 'function' && !key.startsWith('_') && !skip.has(key)) {
200
+ const original = val.bind(client);
201
+ (client as any)[key] = (...a: any[]) => retryPolicy.execute(() => original(...a));
202
+ }
203
+ }
204
+
205
+ // Now every public operation is wrapped.
206
+ ```
207
+
208
+ ### Custom Classification Example
209
+
210
+ Retry only network errors + 429/503, plus optionally 500 on safe GET endpoints you mark:
211
+
212
+ ```ts
213
+ import { retry, ExponentialBackoff, handleWhen } from 'cockatiel';
214
+
215
+ const classify = handleWhen((err) => {
216
+ const status = (err as any)?.status;
217
+ if (status === 429 || status === 503) return true;
218
+ if (status === 500 && (err as any).__opVerb === 'GET') return true; // custom tagging optional
219
+ return err?.name === 'TypeError'; // network errors from fetch
220
+ });
221
+
222
+ const policy = retry(classify, {
223
+ maxAttempts: 5,
224
+ backoff: new ExponentialBackoff({ initialDelay: 100, maxDelay: 2000, jitter: true }),
225
+ });
226
+ ```
227
+
228
+ ### Notes
229
+
230
+ - Keep SDK retries disabled to prevent duplicate layers.
231
+ - SDK synthesizes `Error` objects with a `status` for retry-significant HTTP responses (429, 503, 500), enabling classification.
232
+ - You can tag errors (e.g. assign `err.__opVerb`) in a wrapper if verb-level logic is needed.
233
+ - Future improvement: an official `retryStrategy` injection hook—current approach is non-invasive.
234
+
235
+ > Combine cockatiel retry with a circuit breaker, timeout, or bulkhead policy for more robust behavior in partial outages.
236
+
237
+ ## Global Backpressure (Adaptive Concurrency)
238
+
239
+ The client now includes an internal global backpressure manager that adaptively throttles the number of _initiating_ in‑flight operations when the cluster signals resource exhaustion. It complements (not replaces) per‑request HTTP retry.
240
+
241
+ ### Signals Considered
242
+
243
+ An HTTP response is treated as a backpressure signal when it is classified retryable **and** matches one of:
244
+
245
+ - `429` (Too Many Requests) – always
246
+ - `503` with `title === "RESOURCE_EXHAUSTED"`
247
+ - `500` whose RFC 9457 / 7807 `detail` text contains `RESOURCE_EXHAUSTED`
248
+
249
+ All other 5xx / 503 variants are treated as non‑retryable (fail fast) and do **not** influence the adaptive gate.
250
+
251
+ ### How It Works
252
+
253
+ 1. Normal state starts with effectively unlimited concurrency (no global semaphore enforced) until the first backpressure event.
254
+ 2. On the first signal the manager boots with a provisional concurrency cap (e.g. 16) and immediately reduces it (soft state).
255
+ 3. Repeated consecutive signals escalate severity to `severe`, applying a stronger reduction factor.
256
+ 4. Successful (non‑backpressure) completions trigger passive recovery checks that gradually restore permits over time if the system stays quiet.
257
+ 5. Quiet periods (no signals for a configurable decay interval) downgrade severity (`severe → soft → healthy`) and reset the consecutive counter when fully healthy.
258
+
259
+ The policy is intentionally conservative: it only engages after genuine pressure signals and recovers gradually to avoid oscillation.
260
+
261
+ ### Exempt Operations
262
+
263
+ Certain operations that help drain work or complete execution are _exempt_ from gating so they are never queued behind initiating calls:
264
+
265
+ - `completeJob`
266
+ - `failJob`
267
+ - `throwJobError`
268
+ - `completeUserTask`
269
+
270
+ These continue immediately even during severe backpressure to promote system recovery.
271
+
272
+ ### Interaction With HTTP Retry
273
+
274
+ Per‑request retry still performs exponential backoff + jitter for classified transient errors. The adaptive concurrency layer sits _outside_ retry:
275
+
276
+ 1. A call acquires a permit (unless exempt) before its first attempt.
277
+ 2. Internal retry re‑attempts happen _within_ the held permit.
278
+ 3. On final success the permit is released and a healthy hint is recorded (possible gradual recovery).
279
+ 4. On final failure (non‑retryable or attempts exhausted) the permit is released; a 429 on the terminal attempt still records backpressure.
280
+
281
+ This design prevents noisy churn (permits would not shrink/expand per retry attempt) and focuses on admission control of distinct logical operations.
282
+
283
+ ### Observability
284
+
285
+ Enable debug logging (`CAMUNDA_SDK_LOG_LEVEL=debug` or `trace`) to see events emitted under the scoped logger `bp` (e.g. `backpressure.permits.scale`, `backpressure.permits.recover`, `backpressure.severity`). These are trace‑level; use `trace` for the most granular insight.
286
+
287
+ ### Configuration
288
+
289
+ Current release ships with defaults tuned for conservative behavior. Adaptive gating is controlled by a profile (no separate boolean toggle). Use the `LEGACY` profile for observe‑only mode (no global gating, still records severity). Otherwise choose a tuning profile and optionally override individual knobs.
290
+
291
+ Tuning environment variables (all optional; defaults shown):
292
+
293
+ | Variable | Default | Description |
294
+ | ----------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------- |
295
+ | `CAMUNDA_SDK_BACKPRESSURE_INITIAL_MAX` | `16` | Bootstrap concurrency cap once the first signal is observed (null/unlimited before any signal). |
296
+ | `CAMUNDA_SDK_BACKPRESSURE_SOFT_FACTOR` | `70` | Percentage multiplier applied on each soft backpressure event (70 => 0.70x permits). |
297
+ | `CAMUNDA_SDK_BACKPRESSURE_SEVERE_FACTOR` | `50` | Percentage multiplier when entering or re-triggering in severe state. |
298
+ | `CAMUNDA_SDK_BACKPRESSURE_RECOVERY_INTERVAL_MS` | `1000` | Interval between passive recovery checks. |
299
+ | `CAMUNDA_SDK_BACKPRESSURE_RECOVERY_STEP` | `1` | Permits regained per recovery interval until reaching the bootstrap cap. |
300
+ | `CAMUNDA_SDK_BACKPRESSURE_DECAY_QUIET_MS` | `2000` | Quiet period to downgrade severity (`severe→soft→healthy`). |
301
+ | `CAMUNDA_SDK_BACKPRESSURE_FLOOR` | `1` | Minimum concurrency floor while degraded. |
302
+ | `CAMUNDA_SDK_BACKPRESSURE_SEVERE_THRESHOLD` | `3` | Consecutive signals required to enter severe state. |
303
+ | `CAMUNDA_SDK_BACKPRESSURE_PROFILE` | `BALANCED` | Preset profile: BALANCED, CONSERVATIVE, AGGRESSIVE, LEGACY (LEGACY = observe-only, no gating). |
304
+
305
+ #### Profiles
306
+
307
+ Profiles supply coordinated defaults when you don't want to reason about individual knobs. Any explicitly set knob env var overrides the profile value.
308
+
309
+ | Profile | initialMax | softFactor% | severeFactor% | recoveryIntervalMs | recoveryStep | quietDecayMs | floor | severeThreshold | Intended Use |
310
+ | ------------ | ---------- | ----------- | ------------- | ------------------ | ------------ | ------------ | ----- | --------------- | --------------------------------------------------------------- |
311
+ | BALANCED | 16 | 70 | 50 | 1000 | 1 | 2000 | 1 | 3 | General workloads with moderate spikes |
312
+ | CONSERVATIVE | 12 | 60 | 40 | 1200 | 1 | 2500 | 1 | 2 | Protect cluster under tighter capacity / cost constraints |
313
+ | AGGRESSIVE | 24 | 80 | 60 | 800 | 2 | 1500 | 2 | 4 | High throughput scenarios aiming to utilize headroom quickly |
314
+ | LEGACY | n/a | 70 | 50 | 1000 | 1 | 2000 | 1 | 3 | Observe signals only (severity metrics) without adaptive gating |
315
+
316
+ Select via:
317
+
318
+ ```bash
319
+ CAMUNDA_SDK_BACKPRESSURE_PROFILE=AGGRESSIVE
320
+ ```
321
+
322
+ Then optionally override a single parameter, e.g.:
323
+
324
+ ```bash
325
+ CAMUNDA_SDK_BACKPRESSURE_PROFILE=AGGRESSIVE
326
+ CAMUNDA_SDK_BACKPRESSURE_INITIAL_MAX=32
327
+ ```
328
+
329
+ If the profile name is unrecognized the SDK falls back to BALANCED silently (future versions may emit a warning).
330
+
331
+ Factors use integer percentages to avoid floating point drift in env parsing; the SDK converts them to multipliers internally (e.g. `70` -> `0.7`).
332
+
333
+ If you have concrete tuning needs, open an issue describing workload patterns (operation mix, baseline concurrency, observed broker limits) to help prioritize which knobs to surface.
334
+
335
+ ## Job Workers (Polling API)
336
+
337
+ The SDK provides a lightweight polling job worker for service task job types using `createJobWorker`. It activates jobs in batches (respecting a concurrency limit), validates variables (optional), and offers action helpers on each job.
338
+
339
+ ### Minimal Example
340
+
341
+ ```ts
342
+ import createCamundaClient from '@camunda8/orchestration-cluster-api';
343
+ import { z } from 'zod';
344
+
345
+ const client = createCamundaClient();
346
+
347
+ // Define schemas (optional)
348
+ const Input = z.object({ orderId: z.string() });
349
+ const Output = z.object({ processed: z.boolean() });
350
+
351
+ const worker = client.createJobWorker({
352
+ jobType: 'process-order',
353
+ maxParallelJobs: 10,
354
+ timeoutMs: 15_000, // long‑poll timeout (server side requestTimeout)
355
+ pollIntervalMs: 100, // delay between polls when no jobs / at capacity
356
+ inputSchema: Input, // validates incoming variables if validateSchemas true
357
+ outputSchema: Output, // validates variables passed to complete(...)
358
+ validateSchemas: true, // set false for max throughput (skip Zod)
359
+ autoStart: true, // default true; start polling immediately
360
+ jobHandler: async (job) => {
361
+ // Access typed variables
362
+ const vars = job.variables; // inferred from Input schema
363
+ // Do work...
364
+ await job.complete({ variables: { processed: true } });
365
+ // Returning the receipt is optional; completion already acknowledges the job.
366
+ },
367
+ });
368
+
369
+ // Later, on shutdown:
370
+ process.on('SIGINT', () => {
371
+ worker.stop();
372
+ });
373
+ ```
374
+
375
+ ### Job Handler Semantics
376
+
377
+ Your `jobHandler` must ultimately invoke exactly one of:
378
+
379
+ - `job.complete({ variables? })`
380
+ - `job.fail({ errorMessage, retries?, retryBackoff? })`
381
+ - `job.cancelWorkflow({})` / `job.cancel({})` (alias – cancels the process instance)
382
+ - `job.ignore()` (marks as done locally without reporting to broker – only use for experimental flows)
383
+
384
+ Each action returns an opaque unique symbol receipt (`JobActionReceipt`) primarily for internal/test assertions; you do not need to use it. If the handler returns without invoking an action the worker logs a warning and internally marks the job done (no completion sent) to prevent a leak.
385
+
386
+ ### Concurrency & Backpressure
387
+
388
+ Set `maxParallelJobs` to the maximum number of jobs you want actively processing concurrently. The worker will long‑poll for up to the remaining capacity each cycle. Global backpressure (adaptive concurrency) still applies to the underlying REST calls; activation itself is a normal operation.
389
+
390
+ ### Validation
391
+
392
+ If `validateSchemas` is true:
393
+
394
+ - Incoming `variables` are parsed with `inputSchema` (fail => job is failed with a validation error message).
395
+ - Incoming `customHeaders` parsed with `customHeadersSchema` if provided.
396
+ - Completion payload `variables` parsed with `outputSchema` (warns & proceeds on failure).
397
+
398
+ ### Graceful Shutdown
399
+
400
+ Call `worker.stop()` (or `client.stopAllWorkers()`) to cancel ongoing polling and prevent new activations. Jobs already handed to handlers can finish their HTTP completion/failure calls.
401
+
402
+ ### Multiple Workers
403
+
404
+ You can register multiple workers on a single client instance—one per job type is typical. The client exposes `client.getWorkers()` for inspection and `client.stopAllWorkers()` for coordinated shutdown.
405
+
406
+ ### Receipt Type (Unique Symbol)
407
+
408
+ Action methods return a unique symbol (not a string) to avoid accidental misuse and allow internal metrics. If you store the receipt, annotate its type as `JobActionReceipt` to preserve uniqueness:
409
+
410
+ ```ts
411
+ import { JobActionReceipt } from '@camunda8/orchestration-cluster-api';
412
+ const receipt: JobActionReceipt = await job.complete({ variables: { processed: true } });
413
+ ```
414
+
415
+ If you ignore the return value you don’t need to import the symbol.
416
+
417
+ ### When Not To Use The Worker
418
+
419
+ - Extremely latency‑sensitive tasks where a push mechanism or streaming protocol is required.
420
+ - Massive fan‑out requiring custom partitioning strategies (implement a custom activator loop instead).
421
+ - Browser environments (long‑lived polling + secret handling often unsuitable).
422
+
423
+ For custom strategies you can still call `client.activateJobs(...)`, manage concurrency yourself, and use `completeJob` / `failJob` directly.
424
+
425
+ ### Guarantees & Caveats
426
+
427
+ - Never increases latency for healthy clusters (no cap until first signal).
428
+ - Cannot create fairness across multiple _processes_; it is per client instance in a single process. Scale your worker pool with that in mind.
429
+ - Not a replacement for server‑side quotas or external rate limiters—it's a cooperative adaptive limiter.
430
+ - Exempt list is minimal by design; adding more exemptions without care can reduce effectiveness.
431
+
432
+ ### Opt‑Out
433
+
434
+ To bypass adaptive concurrency while still collecting severity metrics use:
435
+
436
+ ```bash
437
+ CAMUNDA_SDK_BACKPRESSURE_PROFILE=LEGACY
438
+ ```
439
+
440
+ This reverts to only per‑request retry for transient errors (no global gating) while keeping observability.
441
+
442
+ ### Inspecting State Programmatically
443
+
444
+ Call `client.getBackpressureState()` to obtain:
445
+
446
+ ```ts
447
+ {
448
+ severity: 'healthy' | 'soft' | 'severe';
449
+ consecutive: number; // consecutive backpressure signals observed
450
+ permitsMax: number | null; // current concurrency cap (null => unlimited/not engaged)
451
+ permitsCurrent: number; // currently acquired permits
452
+ waiters: number; // queued operations waiting for a permit
453
+ }
454
+ ```
455
+
456
+ ---
457
+
458
+ ## Authentication
459
+
460
+ Set `CAMUNDA_AUTH_STRATEGY` to `NONE` (default), `BASIC`, or `OAUTH`.
461
+
462
+ Basic:
463
+
464
+ ```
465
+ CAMUNDA_AUTH_STRATEGY=BASIC
466
+ CAMUNDA_BASIC_AUTH_USERNAME=alice
467
+ CAMUNDA_BASIC_AUTH_PASSWORD=supersecret
468
+ ```
469
+
470
+ OAuth (client credentials):
471
+
472
+ ```
473
+ CAMUNDA_AUTH_STRATEGY=OAUTH
474
+ CAMUNDA_CLIENT_ID=yourClientId
475
+ CAMUNDA_CLIENT_SECRET=yourSecret
476
+ CAMUNDA_OAUTH_URL=https://idp.example/oauth/token # if required by your deployment
477
+ ```
478
+
479
+ Optional audience / retry / timeout vars are also read if present (see generated config reference).
480
+
481
+ Auth helper features (automatic inside the client):
482
+
483
+ - Disk + memory token cache
484
+ - Early refresh with skew handling
485
+ - Exponential backoff & jitter
486
+ - Singleflight suppression of concurrent refreshes
487
+ - Hook: `client.onAuthHeaders(h => ({ ...h, 'X-Trace': 'abc' }))`
488
+ - Force refresh: `await client.forceAuthRefresh()`
489
+ - Clear caches: `client.clearAuthCache({ disk: true, memory: true })`
490
+
491
+ ### Token Caching & Persistence
492
+
493
+ The SDK always keeps the active OAuth access token in memory. Optional disk persistence (Node only) is enabled by setting:
494
+
495
+ ```bash
496
+ CAMUNDA_OAUTH_CACHE_DIR=/path/to/cache
497
+ ```
498
+
499
+ When present and running under Node, each distinct credential context (combination of `oauthUrl | clientId | audience | scope`) is hashed to a filename:
500
+
501
+ ```
502
+ <CAMUNDA_OAUTH_CACHE_DIR>/camunda_oauth_token_cache_<hash>.json
503
+ ```
504
+
505
+ Writes are atomic (`.tmp` + rename) and use file mode `0600` (owner read/write). On process start the SDK attempts to load the persisted file to avoid an unnecessary token fetch; if the token is near expiry it will still perform an early refresh (5s skew window plus additional safety buffer based on 5% or 30s minimum).
506
+
507
+ Clearing / refreshing:
508
+
509
+ - Programmatic clear: `client.clearAuthCache({ disk: true, memory: true })`
510
+ - Memory only: `client.clearAuthCache({ memory: true, disk: false })`
511
+ - Force new token (ignores freshness): `await client.forceAuthRefresh()`
512
+
513
+ Disable disk persistence by simply omitting `CAMUNDA_OAUTH_CACHE_DIR` (memory cache still applies). For short‑lived or serverless functions you may prefer no disk cache to minimize I/O; for long‑running workers disk caching reduces cold‑start latency and load on the identity provider across restarts / rolling deploys.
514
+
515
+ Security considerations:
516
+
517
+ - Ensure the directory has restrictive ownership/permissions; the SDK creates files with `0600` but will not alter parent directory permissions.
518
+ - Tokens are bearer credentials; treat the directory like a secrets store and avoid including it in container image layers or backups.
519
+ - If you rotate credentials (client secret) the filename hash changes; old cache files become unused and can be pruned safely.
520
+
521
+ Browser usage: There is no disk concept—if executed in a browser the SDK (when strategy OAUTH) attempts to store the token in `sessionStorage` (tab‑scoped). Closing the tab clears the cache; a new tab will fetch a fresh token.
522
+
523
+ If you need a custom persistence strategy (e.g. Redis / encrypted keychain), wrap the client and periodically call `client.forceAuthRefresh()` while storing and re‑injecting the token via a headers hook; first measure whether the built‑in disk cache already meets your needs.
524
+
525
+ ## mTLS (Node only)
526
+
527
+ Provide inline or path variables (inline wins):
528
+
529
+ ```
530
+ CAMUNDA_MTLS_CERT / CAMUNDA_MTLS_CERT_PATH
531
+ CAMUNDA_MTLS_KEY / CAMUNDA_MTLS_KEY_PATH
532
+ CAMUNDA_MTLS_CA / CAMUNDA_MTLS_CA_PATH (optional)
533
+ CAMUNDA_MTLS_KEY_PASSPHRASE (optional)
534
+ ```
535
+
536
+ If both cert & key are available an https.Agent is attached to all outbound calls (including token fetches).
537
+
538
+ ## Branded Keys
539
+
540
+ Import branded key helpers directly:
541
+
542
+ ```ts
543
+ import { ProcessDefinitionKey, ProcessInstanceKey } from '@camunda8/orchestration-cluster';
544
+
545
+ const defKey = ProcessDefinitionKey.assumeExists('2251799813686749');
546
+ // @ts-expect-error – cannot assign def key to instance key
547
+ const bad: ProcessInstanceKey = defKey;
548
+ ```
549
+
550
+ They are zero‑cost runtime strings with compile‑time separation.
551
+
552
+ ## Cancelable Operations
553
+
554
+ All methods return a `CancelablePromise<T>`:
555
+
556
+ ```ts
557
+ const p = camunda.searchProcessInstances({ filter: { processDefinitionKey: defKey } });
558
+ setTimeout(() => p.cancel(), 100); // best‑effort cancel
559
+ await p; // rejects with CancelError if aborted
560
+ ```
561
+
562
+ ## Functional (fp-ts style) Surface (Opt-In Subpath)
563
+
564
+ The main entry stays minimal. To opt in to a TaskEither-style facade & helper combinators import from the dedicated subpath:
565
+
566
+ ```ts
567
+ import {
568
+ createCamundaFpClient,
569
+ retryTE,
570
+ withTimeoutTE,
571
+ eventuallyTE,
572
+ isLeft,
573
+ } from '@camunda8/orchestration-cluster/fp';
574
+
575
+ const fp = createCamundaFpClient();
576
+ const deployTE = fp.deployResourcesFromFiles(['./bpmn/process.bpmn']);
577
+ const deployed = await deployTE();
578
+ if (isLeft(deployed)) throw deployed.left; // DomainError union
579
+
580
+ // Chain with fp-ts (optional) – the returned thunks are structurally compatible with TaskEither
581
+ // import { pipe } from 'fp-ts/function'; import * as TE from 'fp-ts/TaskEither';
582
+ ```
583
+
584
+ Why a subpath?
585
+
586
+ - Keeps base bundle lean for the 80% use case.
587
+ - No hard dependency on `fp-ts` at runtime; only structural types.
588
+ - Advanced users can compose with real `fp-ts` without pulling the effect model into the default import path.
589
+
590
+ Exports available from `.../fp`:
591
+
592
+ - `createCamundaFpClient` – typed facade (methods return `() => Promise<Either<DomainError,T>>`).
593
+ - Type guards: `isLeft`, `isRight`.
594
+ - Error / type aliases: `DomainError`, `TaskEither`, `Either`, `Left`, `Right`, `Fpify`.
595
+ - Combinators: `retryTE`, `withTimeoutTE`, `eventuallyTE`.
596
+
597
+ DomainError union currently includes:
598
+
599
+ - `CamundaValidationError`
600
+ - `EventualConsistencyTimeoutError`
601
+ - HTTP-like error objects (status/body/message) produced by transport
602
+ - Generic `Error`
603
+
604
+ You can refine left-channel typing later by mapping HTTP status codes or discriminator fields.
605
+
606
+ ## Eventual Consistency Polling
607
+
608
+ Some endpoints accept consistency management options. Pass a `consistency` block (where supported) with `waitUpToMs` and optional `pollIntervalMs` (default 500). If the condition is not met within timeout an `EventualConsistencyTimeoutError` is thrown.
609
+
610
+ To consume eventual polling in a non‑throwing fashion set the client error mode before invoking an eventually consistent method:
611
+ At present the canonical client operates in throwing mode. Non‑throwing adaptation (Result / fp-ts) is achieved via the functional wrappers rather than mutating the base client.
612
+
613
+ ### Options
614
+
615
+ `consistency` object fields (all optional except `waitUpToMs`):
616
+
617
+ | Field | Type | Description |
618
+ | ---------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
619
+ | `waitUpToMs` | `number` | Maximum total time to wait before failing. `0` disables polling and returns the first response immediately. |
620
+ | `pollIntervalMs` | `number` | Base delay between attempts (minimum enforced at 10ms). Defaults to `500` or the value of `CAMUNDA_SDK_EVENTUAL_POLL_DEFAULT_MS` if provided. |
621
+ | `predicate` | `(result) => boolean \| Promise<boolean>` | Custom success condition. If omitted, non-GET endpoints default to: first 2xx body whose `items` array (if present) is non-empty. |
622
+ | `trace` | `boolean` | When true, logs each 200 response body (truncated ~1KB) before predicate evaluation and emits a success line with elapsed time when the predicate passes. Requires log level `debug` (or `trace`) to see output. |
623
+ | `onAttempt` | `(info) => void` | Callback after each attempt: `{ attempt, elapsedMs, remainingMs, status, predicateResult, nextDelayMs }`. |
624
+ | `onComplete` | `(info) => void` | Callback when predicate succeeds: `{ attempts, elapsedMs }`. Not called on timeout. |
625
+
626
+ ### Trace Logging
627
+
628
+ Enable by setting `trace: true` inside `consistency`. Output appears under the `eventual` log scope at level `debug` so you must raise the SDK log level (e.g. `CAMUNDA_SDK_LOG_LEVEL=debug`).
629
+
630
+ Emitted lines (examples):
631
+
632
+ ```
633
+ [camunda-sdk][debug][eventual] op=searchJobs attempt=3 trace body={"items":[]}
634
+ [camunda-sdk][debug][eventual] op=searchJobs attempt=5 status=200 predicate=true elapsed=742ms totalAttempts=5
635
+ ```
636
+
637
+ Use this to understand convergence speed and data shape evolution during tests or to diagnose slow propagation.
638
+
639
+ ### Example
640
+
641
+ ```ts
642
+ const jobs = await camunda.searchJobs({
643
+ filter: { type: 'payment' },
644
+ consistency: {
645
+ waitUpToMs: 5000,
646
+ pollIntervalMs: 200,
647
+ trace: true,
648
+ predicate: (r) => Array.isArray(r.items) && r.items.some((j) => j.state === 'CREATED'),
649
+ },
650
+ });
651
+ ```
652
+
653
+ On timeout an `EventualConsistencyTimeoutError` includes diagnostic fields: `{ attempts, elapsedMs, lastStatus, lastResponse, operationId }`.
654
+
655
+ ## Logging
656
+
657
+ Per‑client logger; no global singleton. The level defaults from `CAMUNDA_SDK_LOG_LEVEL` (default `error`).
658
+
659
+ ```ts
660
+ const client = createCamundaClient({
661
+ log: {
662
+ level: 'info',
663
+ transport: (evt) => {
664
+ // evt: { level, scope, ts, args, code?, data? }
665
+ console.log(JSON.stringify(evt));
666
+ },
667
+ },
668
+ });
669
+
670
+ const log = client.logger('worker');
671
+ log.debug(() => ['expensive detail only if enabled', { meta: 1 }]);
672
+ log.code('info', 'WORK_START', 'Starting work loop', { pid: process.pid });
673
+ ```
674
+
675
+ Lazy args (functions with zero arity) are only invoked if the level is enabled.
676
+
677
+ Update log level / transport at runtime via `client.configure({ log: { level: 'debug' } })`.
678
+
679
+ ### Default Behaviour
680
+
681
+ Without any explicit `log` option:
682
+
683
+ - Level = `error` (unless `CAMUNDA_SDK_LOG_LEVEL` is set)
684
+ - Transport = console (`console.error` / `console.warn` / `console.log`)
685
+ - Only `error` level internal events are emitted (e.g. strict validation failure summaries, fatal auth issues)
686
+ - No info/debug/trace noise by default
687
+
688
+ To silence everything set level to `silent`:
689
+
690
+ ```bash
691
+ CAMUNDA_SDK_LOG_LEVEL=silent
692
+ ```
693
+
694
+ To enable debug logs via env:
695
+
696
+ ```bash
697
+ CAMUNDA_SDK_LOG_LEVEL=debug
698
+ ```
699
+
700
+ ### Bring Your Own Logger
701
+
702
+ Provide a `transport` function to forward structured `LogEvent` objects into any logging library.
703
+
704
+ #### Pino
705
+
706
+ ```ts
707
+ import pino from 'pino';
708
+ import createCamundaClient from '@camunda8/orchestration-cluster';
709
+
710
+ const p = pino();
711
+ const client = createCamundaClient({
712
+ log: {
713
+ level: 'info',
714
+ transport: e => {
715
+ const lvl = e.level === 'trace' ? 'debug' : e.level; // map trace
716
+ p.child({ scope: e.scope, code: e.code }).[lvl]({ ts: e.ts, data: e.data, args: e.args }, e.args.filter(a=>typeof a==='string').join(' '));
717
+ }
718
+ }
719
+ });
720
+ ```
721
+
722
+ #### Winston
723
+
724
+ ```ts
725
+ import winston from 'winston';
726
+ import createCamundaClient from '@camunda8/orchestration-cluster';
727
+
728
+ const w = winston.createLogger({ transports: [new winston.transports.Console()] });
729
+ const client = createCamundaClient({
730
+ log: {
731
+ level: 'debug',
732
+ transport: (e) => {
733
+ const lvl = e.level === 'trace' ? 'silly' : e.level; // winston has 'silly'
734
+ w.log({
735
+ level: lvl,
736
+ message: e.args.filter((a) => typeof a === 'string').join(' '),
737
+ scope: e.scope,
738
+ code: e.code,
739
+ data: e.data,
740
+ ts: e.ts,
741
+ });
742
+ },
743
+ },
744
+ });
745
+ ```
746
+
747
+ #### loglevel
748
+
749
+ ```ts
750
+ import log from 'loglevel';
751
+ import createCamundaClient from '@camunda8/orchestration-cluster';
752
+
753
+ log.setLevel('info'); // host app level
754
+ const client = createCamundaClient({
755
+ log: {
756
+ level: 'info',
757
+ transport: (e) => {
758
+ if (e.level === 'silent') return;
759
+ const method = (['error', 'warn', 'info', 'debug'].includes(e.level) ? e.level : 'debug') as
760
+ | 'error'
761
+ | 'warn'
762
+ | 'info'
763
+ | 'debug';
764
+ (log as any)[method](`[${e.scope}]`, e.code ? `${e.code}:` : '', ...e.args);
765
+ },
766
+ },
767
+ });
768
+ ```
769
+
770
+ #### Notes
771
+
772
+ - Map `trace` to the nearest available level if your logger lacks it.
773
+ - Use `log.code(level, code, msg, data)` for machine-parsable events.
774
+ - Redact secrets before logging if you add token contents to custom messages.
775
+ - Reconfigure later: `client.configure({ log: { level: 'warn' } })` updates only that client.
776
+ - When the effective level is `debug` (or `trace`), the client emits a lazy `config.hydrated` event on construction and `config.reconfigured` on `configure()`, each containing the redacted effective configuration `{ config: { CAMUNDA_... } }`. Secrets are already masked using the SDK's redaction rules.
777
+
778
+ ## Errors
779
+
780
+ May throw:
781
+
782
+ - Network / fetch failures
783
+ - Non‑2xx HTTP responses
784
+ - Validation errors (strict mode)
785
+ - `EventualConsistencyTimeoutError`
786
+ - `CancelError` on cancellation
787
+
788
+ ### Typed Error Handling
789
+
790
+ All SDK-thrown operational errors normalize to a discriminated union (`SdkError`) when they originate from HTTP, network, auth, or validation layers. Use the guard `isSdkError` to narrow inside a catch:
791
+
792
+ ```ts
793
+ import { createCamundaClient } from '@camunda8/orchestration-cluster-api';
794
+ import { isSdkError } from '@camunda8/orchestration-cluster-api/dist/runtime/errors';
795
+
796
+ const client = createCamundaClient();
797
+
798
+ try {
799
+ await client.getTopology();
800
+ } catch (e) {
801
+ if (isSdkError(e)) {
802
+ switch (e.name) {
803
+ case 'HttpSdkError':
804
+ console.error('HTTP failure', e.status, e.operationId);
805
+ break;
806
+ case 'ValidationSdkError':
807
+ console.error('Validation issue on', e.operationId, e.side, e.issues);
808
+ break;
809
+ case 'AuthSdkError':
810
+ console.error('Auth problem', e.message, e.status);
811
+ break;
812
+ case 'NetworkSdkError':
813
+ console.error('Network layer error', e.message);
814
+ break;
815
+ }
816
+ return;
817
+ }
818
+ // Non-SDK (programmer) error; rethrow or wrap
819
+ throw e;
820
+ }
821
+ ```
822
+
823
+ Guarantees:
824
+
825
+ - HTTP errors expose `status` and optional `operationId`.
826
+ - If the server returns RFC 9457 / RFC 7807 Problem Details JSON (`type`, `title`, `status`, `detail`, `instance`) these fields are passed through on the `HttpSdkError` when present.
827
+ - Validation errors expose `side` and `operationId`.
828
+ - Classification is best-effort; unknown shapes fall back to `NetworkSdkError`.
829
+
830
+ > Advanced: You can still layer your own domain errors on top (e.g. translate certain status codes) by mapping `SdkError` into custom discriminants.
831
+
832
+ ### Functional / Non‑Throwing Variant - EXPERIMENTAL
833
+
834
+ _Note that this feature is experimental and subject to change._
835
+
836
+ If you prefer FP‑style explicit error handling instead of exceptions, use the result client wrapper:
837
+
838
+ ```ts
839
+ import { createCamundaResultClient, isOk } from '@camunda8/orchestration-cluster';
840
+
841
+ const camundaR = createCamundaResultClient();
842
+ const res = await camundaR.createDeployment({ resources: [file] });
843
+ if (isOk(res)) {
844
+ console.log('Deployment key', res.value.deployments[0].deploymentKey);
845
+ } else {
846
+ console.error('Deployment failed', res.error);
847
+ }
848
+ ```
849
+
850
+ API surface differences:
851
+
852
+ - All async operation methods return `Promise<Result<T>>` where `Result<T> = { ok: true; value: T } | { ok: false; error: unknown }`.
853
+ - No exceptions are thrown for HTTP / validation errors (cancellation and programmer errors like invalid argument sync throws are still converted to `{ ok:false }`).
854
+ - The original throwing client is available via `client.inner` if you need to mix styles.
855
+
856
+ Helpers:
857
+
858
+ ```ts
859
+ import { isOk, isErr } from '@camunda8/orchestration-cluster';
860
+ ```
861
+
862
+ When to use:
863
+
864
+ - Integrating with algebraic effects / functional pipelines.
865
+ - Avoiding try/catch nesting in larger orchestration flows.
866
+ - Converting to libraries expecting an Either/Result pattern.
867
+
868
+ ### fp-ts Adapter (TaskEither / Either) - EXPERIMENTAL
869
+
870
+ _Note that this feature is experimental and subject to change._
871
+
872
+ For projects using `fp-ts`, wrap the throwing client in a lazy `TaskEither` facade:
873
+
874
+ ```ts
875
+ import { createCamundaFpClient } from '@camunda8/orchestration-cluster';
876
+ import { pipe } from 'fp-ts/function';
877
+ import * as TE from 'fp-ts/TaskEither';
878
+
879
+ const fp = createCamundaFpClient();
880
+
881
+ const deployTE = fp.createDeployment({ resources: [file] }); // TaskEither<unknown, ExtendedDeploymentResult>
882
+
883
+ pipe(
884
+ deployTE(), // invoke the task (returns Promise<Either>)
885
+ (then) => then // typical usage would use TE.match / TE.fold; shown expanded for clarity
886
+ );
887
+
888
+ // With helpers
889
+ const task = fp.createDeployment({ resources: [file] });
890
+ const either = await task();
891
+ if (either._tag === 'Right') {
892
+ console.log(either.right.deployments.length);
893
+ } else {
894
+ console.error('Error', either.left);
895
+ }
896
+ ```
897
+
898
+ Notes:
899
+
900
+ - No runtime dependency on `fp-ts`; adapter implements a minimal `Either` shape. Structural typing lets you lift into real `fp-ts` functions (`fromEither`, etc.).
901
+ - Each method becomes a function returning `() => Promise<Either<E,A>>` (a `TaskEither` shape). Invoke it later to execute.
902
+ - Cancellation: calling `.cancel()` on the original promise isn’t surfaced; if you need cancellation use the base client directly.
903
+ - For richer interop, you can map the returned factory to `TE.tryCatch` in userland.
904
+
905
+ ## Pagination
906
+
907
+ Search endpoints expose typed request bodies that include pagination fields. Provide the desired page object; auto‑pagination is not (yet) bundled.
908
+
909
+ ## Configuration Reference
910
+
911
+ Generated doc enumerating all supported environment variables (types, defaults, conditional requirements, redaction rules) is produced at build time:
912
+
913
+ ```
914
+ ./docs/CONFIG_REFERENCE.md
915
+ ```
916
+
917
+ ## Deploying Resources (File-only)
918
+
919
+ The deployment endpoint requires each resource to have a filename (extension used to infer type: `.bpmn`, `.dmn`, `.form` / `.json`). Extensions influence server classification; incorrect or missing extensions may yield unexpected results. Pass an array of `File` objects (NOT plain `Blob`).
920
+
921
+ ### Browser
922
+
923
+ ```ts
924
+ const bpmnXml = `<definitions id="process" xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL">...</definitions>`;
925
+ const file = new File([bpmnXml], 'order-process.bpmn', { type: 'application/xml' });
926
+ const result = await camunda.createDeployment({ resources: [file] });
927
+ console.log(result.deployments.length);
928
+ ```
929
+
930
+ From an existing Blob:
931
+
932
+ ```ts
933
+ const blob: Blob = getBlob();
934
+ const file = new File([blob], 'model.bpmn');
935
+ await camunda.createDeployment({ resources: [file] });
936
+ ```
937
+
938
+ ### Node (Recommended Convenience)
939
+
940
+ Use the built-in helper `deployResourcesFromFiles(...)` to read local files and create `File` objects automatically. It returns the enriched `ExtendedDeploymentResult` (adds typed arrays: `processes`, `decisions`, `decisionRequirements`, `forms`, `resources`).
941
+
942
+ ```ts
943
+ const result = await camunda.deployResourcesFromFiles([
944
+ './bpmn/order-process.bpmn',
945
+ './dmn/discount.dmn',
946
+ './forms/order.form',
947
+ ]);
948
+
949
+ console.log(result.processes.map((p) => p.processDefinitionId));
950
+ console.log(result.decisions.length);
951
+ ```
952
+
953
+ With explicit tenant (overriding tenant from configuration):
954
+
955
+ ```ts
956
+ await camunda.deployResourcesFromFiles(['./bpmn/order-process.bpmn'], { tenantId: 'tenant-a' });
957
+ ```
958
+
959
+ Error handling:
960
+
961
+ ```ts
962
+ try {
963
+ await camunda.deployResourcesFromFiles([]); // throws (empty array)
964
+ } catch (e) {
965
+ console.error('Deployment failed:', e);
966
+ }
967
+ ```
968
+
969
+ Manual construction alternative (if you need custom logic):
970
+
971
+ ```ts
972
+ import { File } from 'node:buffer';
973
+ const bpmnXml =
974
+ '<definitions id="process" xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"></definitions>';
975
+ const file = new File([Buffer.from(bpmnXml)], 'order-process.bpmn', { type: 'application/xml' });
976
+ await camunda.createDeployment({ resources: [file] });
977
+ ```
978
+
979
+ Helper behavior:
980
+
981
+ - Dynamically imports `node:fs/promises` & `node:path` (tree-shaken from browser bundles)
982
+ - Validates Node environment (throws in browsers)
983
+ - Lightweight MIME inference: `.bpmn|.dmn|.xml -> application/xml`, `.json|.form -> application/json`, fallback `application/octet-stream`
984
+ - Rejects empty path list
985
+
986
+ Empty arrays are rejected. Always use correct extensions so the server can classify each resource.
987
+
988
+ ## Testing Patterns
989
+
990
+ Create isolated clients per test file:
991
+
992
+ ```ts
993
+ const client = createCamundaClient({
994
+ config: { CAMUNDA_REST_ADDRESS: 'http://localhost:8080', CAMUNDA_AUTH_STRATEGY: 'NONE' },
995
+ });
996
+ ```
997
+
998
+ Inject a mock fetch:
999
+
1000
+ ```ts
1001
+ const client = createCamundaClient({
1002
+ fetch: async (input, init) => new Response(JSON.stringify({ ok: true }), { status: 200 }),
1003
+ });
1004
+ ```
1005
+
1006
+ ## License
1007
+
1008
+ Apache 2.0
1009
+
1010
+ ## Deterministic Build Mode
1011
+
1012
+ To eliminate spurious publish drift caused by timestamps and formatting churn in generated artifacts, the SDK supports a deterministic build mode gated by the environment variable `CAMUNDA_SDK_DETERMINISTIC_BUILD=1`.
1013
+
1014
+ When set:
1015
+
1016
+ - All generation scripts use a fixed timestamp (`1970-01-01T00:00:00.000Z`) instead of `new Date().toISOString()`.
1017
+ - Build info writer and doc generators produce stable content.
1018
+ - Upstream spec cloning still occurs, but we exclude auto-formatting of generated files via `.prettierignore` to avoid style oscillation.
1019
+
1020
+ Release workflow strategy:
1021
+
1022
+ 1. Generate job runs with `CAMUNDA_SDK_DETERMINISTIC_BUILD=1`, producing canonical artifacts. If drift is detected they are committed on a temp branch and fast‑forwarded to `main`.
1023
+ 2. Publish job re-runs the deterministic build and performs `git diff --exit-code` to assert zero drift before `semantic-release`.
1024
+
1025
+ Local verification:
1026
+
1027
+ ```bash
1028
+ CAMUNDA_SDK_DETERMINISTIC_BUILD=1 npm run build
1029
+ git diff --exit-code # should be clean after baseline committed
1030
+ ```
1031
+
1032
+ If you see persistent diff after following the above, ensure you have not manually edited generated files and that your local commit hooks (lint-staged) respect `.prettierignore`.
1033
+
1034
+ Do not hand-edit files under `src/gen/`, `branding/branding-metadata.json`, `tests-integration/methods/manifest.json`, or `docs/CONFIG_REFERENCE.md`; regenerate instead.