@camunda8/orchestration-cluster-api 10.0.0-alpha.5 → 10.0.0-alpha.50

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 (38) hide show
  1. package/CHANGELOG.md +365 -0
  2. package/README.md +470 -85
  3. package/dist/{index-CBhZBupS.d.cts → CamundaClient-DBZ5xkfB.d.cts} +20579 -11210
  4. package/dist/{index-CMbPTSiX.d.ts → CamundaClient-DuZf_I-y.d.ts} +20579 -11210
  5. package/dist/{chunk-S3RXIYYE.js → chunk-GMNYLSVD.js} +12311 -7503
  6. package/dist/chunk-GMNYLSVD.js.map +1 -0
  7. package/dist/{chunk-KQ4UL2WX.js → chunk-LXWPMQOS.js} +1 -1
  8. package/dist/chunk-LXWPMQOS.js.map +1 -0
  9. package/dist/{fp → effect}/index.cjs +18556 -12212
  10. package/dist/effect/index.cjs.map +1 -0
  11. package/dist/effect/index.d.cts +315 -0
  12. package/dist/effect/index.d.ts +315 -0
  13. package/dist/effect/index.js +383 -0
  14. package/dist/effect/index.js.map +1 -0
  15. package/dist/index.cjs +18541 -12148
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +98 -6
  18. package/dist/index.d.ts +98 -6
  19. package/dist/index.js +217 -11
  20. package/dist/index.js.map +1 -1
  21. package/dist/logger.cjs.map +1 -1
  22. package/dist/logger.js +1 -1
  23. package/dist/threadWorkerEntry.cjs +86 -51
  24. package/dist/threadWorkerEntry.cjs.map +1 -1
  25. package/dist/threadWorkerEntry.js +86 -51
  26. package/dist/threadWorkerEntry.js.map +1 -1
  27. package/dist/zod.gen-PGIXLAJW.js +9523 -0
  28. package/dist/zod.gen-PGIXLAJW.js.map +1 -0
  29. package/package.json +48 -30
  30. package/dist/chunk-KQ4UL2WX.js.map +0 -1
  31. package/dist/chunk-S3RXIYYE.js.map +0 -1
  32. package/dist/fp/index.cjs.map +0 -1
  33. package/dist/fp/index.d.cts +0 -4
  34. package/dist/fp/index.d.ts +0 -4
  35. package/dist/fp/index.js +0 -23
  36. package/dist/fp/index.js.map +0 -1
  37. package/dist/zod.gen-UJLBQNEH.js +0 -8225
  38. package/dist/zod.gen-UJLBQNEH.js.map +0 -1
package/README.md CHANGED
@@ -155,20 +155,11 @@ await camunda.createDeployment({
155
155
 
156
156
  ## Migrating from 8.9
157
157
 
158
- SDK 10.x (for Camunda 8.10) promotes several identifier and name fields from plain `string` to **branded types** via `CamundaKey<T>`. The wire format and runtime API are unchanged — branded values are still plain strings at runtime and are assignable anywhere a `string` is expected (template literals, logging, JSON serialization). Callers need to brand values using `.assumeExists()` (which performs validation) to satisfy the new types.
158
+ SDK 10.x (for Camunda 8.10) promotes several identifier fields from plain `string` to **branded types**, changes the `getResourceContent` response from a string to an object, and makes `BatchOperationItemResponse.processInstanceKey` nullable. Nothing was removed or renamed, and the wire format is unchanged.
159
159
 
160
- ### New branded types
160
+ **→ See [MIGRATION.md](./MIGRATION.md) for the full guide**, including the complete list of affected fields.
161
161
 
162
- | Brand | Used for |
163
- |-------|----------|
164
- | `RoleId` | Role identifiers |
165
- | `GroupId` | Group identifiers |
166
- | `ClientId` | OAuth client identifiers |
167
- | `MappingRuleId` | Mapping-rule identifiers |
168
- | `ClusterVariableName` | Cluster variable names |
169
- | `AgentInstanceKey` | Agent-instance system keys |
170
-
171
- ### Migration
162
+ The common case is branding an identifier at the boundary:
172
163
 
173
164
  <!-- snippet-source: examples/readme.ts | regions: V9ToV10Migration -->
174
165
 
@@ -188,12 +179,6 @@ await camunda.assignRoleToGroup({
188
179
 
189
180
  Each branded type has an `.assumeExists()` method that validates the string and returns the branded value. Validation runs at call time and can throw if the input is malformed, so call it once at the boundary (startup, config parsing, API response) and pass the branded value through your application. See [Branded Keys](#branded-keys) for more on this pattern.
190
181
 
191
- ### What does NOT change
192
-
193
- - The wire format is unchanged — all values are still strings on the wire.
194
- - No method signatures changed name or arity.
195
- - Branded values are assignable anywhere a `string` is expected (template literals, logging, JSON serialization), so existing string-handling code continues to work.
196
-
197
182
  ## Quick Start (Zero‑Config – Recommended)
198
183
 
199
184
  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.
@@ -214,6 +199,7 @@ Typical `.env` (example):
214
199
 
215
200
  ```bash
216
201
  CAMUNDA_REST_ADDRESS=https://cluster.example # SDK will use https://cluster.example/v2/... unless /v2 already present
202
+ # CAMUNDA_REST_ADDRESS_EXACT=true # optional (specialized): suppress the /v2 suffix (trailing slashes/whitespace are still normalized) — only for gateway/reverse-proxy base paths
217
203
  CAMUNDA_AUTH_STRATEGY=OAUTH
218
204
  CAMUNDA_CLIENT_ID=***
219
205
  CAMUNDA_CLIENT_SECRET=***
@@ -617,6 +603,41 @@ Benchmark results against a single-node local cluster with multiple independent
617
603
 
618
604
  BALANCED wins 3 of 4 on pure throughput. The only scenario where LEGACY is faster is extreme overload (800 concurrent requests against a single broker) — and in that case LEGACY accumulates 44,505 errors vs BALANCED's 15,527. The default just works.
619
605
 
606
+ ## Typed Variable Map (DTO-driven search)
607
+
608
+ `searchVariablesAsDto` fetches process variables and binds them to a [Zod](https://zod.dev) schema that acts as the DTO. The schema's keys are the exact variable names to fetch, and its shape drives validation. Only the declared variables are queried (via a `name $in [...]` filter), so memory stays bound by the DTO shape rather than the total number of variables on the instance. Results are paged internally until every declared variable is found or the result set is exhausted.
609
+
610
+ The returned `VariableMap` offers two access modes:
611
+
612
+ - **Lenient** — `has(name)` / `get(name)` for defensive reads that never throw on missing variables.
613
+ - **Strict** — `validate()` returns a fully-typed object, or throws a `ZodError` when a required variable is missing or malformed.
614
+
615
+ If a declared variable is found at more than one scope (for example a local variable shadowing a process-level one), the search throws a `VariableScopeCollisionError` rather than silently picking one. Pass an explicit `scopeKey` to disambiguate.
616
+
617
+ <!-- snippet-source: examples/readme.ts | regions: ReadmeTypedVariables -->
618
+
619
+ ```ts
620
+ // The Zod schema is the DTO: its keys are the variable names to fetch, and its
621
+ // shape drives validation. Only these declared variables are queried, so memory
622
+ // stays bound by the DTO — not by the total number of variables on the instance.
623
+ const OrderVariables = z.object({
624
+ orderId: z.string(), // required
625
+ amount: z.number().optional(), // optional
626
+ });
627
+
628
+ const map = await camunda.searchVariablesAsDto(OrderVariables, { processInstanceKey });
629
+
630
+ // Lenient access: defensive reads that never throw on missing variables.
631
+ if (map.has('amount')) {
632
+ console.log('Amount:', map.get('amount'));
633
+ }
634
+
635
+ // Strict access: returns a fully-typed object, or throws a ZodError when a
636
+ // required variable is missing or malformed.
637
+ const order = map.validate(); // { orderId: string; amount?: number }
638
+ console.log('Order:', order.orderId);
639
+ ```
640
+
620
641
  ## Job Workers (Polling API)
621
642
 
622
643
  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.
@@ -731,6 +752,111 @@ return ack;
731
752
  const ack2 = await job.ignore();
732
753
  ```
733
754
 
755
+ ### Deterministic Time (`job.clock`)
756
+
757
+ The SDK resolves its own cadence — worker poll intervals, retry backoff, eventual-consistency
758
+ polling, backpressure decay — through an injectable clock. Pinning that clock runs all of it
759
+ on virtual time, so tests that would otherwise wait out a 30-second poll finish immediately.
760
+
761
+ The clock is configured on the client and available as `client.clock`. Handlers reach it as
762
+ `job.clock`, a narrowed view exposing only `now()` and `sleep(ms, signal?)` — `deadline` is
763
+ withheld because a handler that built one against a pinned clock would hang rather than time
764
+ out:
765
+
766
+ <!-- snippet-source: examples/readme.ts | regions: ReadmeHandlerClock -->
767
+
768
+ ```ts
769
+ const startedAt = job.clock.now();
770
+
771
+ // A short back-off around a flaky dependency. Waiting here rather than on
772
+ // setTimeout means a test that pins the client's clock also drives the handler.
773
+ await job.clock.sleep(250);
774
+
775
+ return job.complete({ variables: { waitedMs: job.clock.now() - startedAt } });
776
+ ```
777
+
778
+ Read and wait through `job.clock` rather than `Date.now()` / `setTimeout`, and a test that
779
+ pins the client's clock drives your handler too.
780
+
781
+ `job.clock.sleep` is for **short in-handler coordination** — spacing retries within one job,
782
+ backing off around a flaky dependency. Long or business-meaningful waits belong in the
783
+ process as BPMN timers, where they survive a crash and are visible to operations.
784
+
785
+ Pass `createTestClock()` to pin the clock in your own tests:
786
+
787
+ <!-- snippet-source: examples/readme.ts | regions: ReadmeTestClock -->
788
+
789
+ ```ts
790
+ // Pin the client's clock and the SDK's own cadence runs on virtual time: poll intervals,
791
+ // retry backoff and backpressure decay all settle without waiting in real time.
792
+ const clock = createTestClock({ start: 0, autoAdvance: false });
793
+ const client = createCamundaClient({ clock });
794
+
795
+ // Nothing settles until the test moves time, so start the wait and advance into it.
796
+ const waiting = client.clock.sleep(30_000);
797
+ await clock.advance(30_000);
798
+ await waiting;
799
+
800
+ console.log(client.clock.now()); // 30000
801
+ console.log(clock.sleeps); // [30000] — every duration the SDK asked to wait
802
+ ```
803
+
804
+ `autoAdvance` defaults to `true`, where each sleep settles itself on the next macrotask
805
+ having moved time to its wake point — the SDK's loops make progress without the test driving
806
+ them. Set it to `false`, as above, when you need to assert on state *between* two waits.
807
+
808
+ #### Binding the SDK to the engine clock
809
+
810
+ `createTestClock` pins the SDK in isolation: the engine carries on at real time. When you are
811
+ testing against a live engine, `createEngineClock` binds the two together so they advance as
812
+ one — `sleep` moves engine time forward via `PUT /clock` instead of waiting:
813
+
814
+ <!-- snippet-source: examples/readme.ts | regions: ReadmeEngineClock -->
815
+
816
+ ```ts
817
+ // Bind the SDK's cadence to the engine's own clock. `sleep` no longer waits — it moves
818
+ // engine time forward — so a worker polling for something that never arrives advances the
819
+ // engine instead of burning real seconds.
820
+ //
821
+ // Two clients, deliberately. `client` issues the pins and must stay on the live clock:
822
+ // HTTP retry sleeps on whatever clock its client was given, so pointing the engine clock
823
+ // at its own driver would have a failed pin back off through `sleep`, which issues another
824
+ // pin, and so on.
825
+ const client = createCamundaClient();
826
+ const clock = createEngineClock(client, { start: Date.now() });
827
+ const pinned = createCamundaClient({ clock });
828
+
829
+ await clock.pin(Date.now());
830
+ try {
831
+ // A minute of engine time. BPMN timers due inside it fire; the test does not wait.
832
+ await pinned.clock.sleep(60_000);
833
+ } finally {
834
+ await clock.reset(); // hand the engine back to real time
835
+ }
836
+ ```
837
+
838
+ This is what makes a worker loop deterministic end to end: the poll interval *drives* engine
839
+ time rather than racing it, so a test that would spend a real minute waiting on something
840
+ that never becomes ready finishes as fast as the requests complete.
841
+
842
+ > [!WARNING]
843
+ > Pinning is global to the cluster. Only point an engine clock at an engine you own —
844
+ > never a shared environment. Always `reset()` in a `finally`.
845
+
846
+ > [!IMPORTANT]
847
+ > The client you hand to `createEngineClock` must not itself be configured with that clock.
848
+ > HTTP retry backs off on whatever clock its client was given, so a self-referential setup
849
+ > would have a failed `pinClock` retry through `sleep`, which issues another `pinClock`.
850
+ > Keep the driving client on the live clock, as in the example above.
851
+
852
+ Prefer `createTestClock` over hand-writing a `Clock`. The contract has clauses that are easy
853
+ to get subtly wrong — most notably that `sleep` must not settle in a microtask, because the
854
+ worker schedules its next poll on resolution and would otherwise spin.
855
+
856
+ Two things deliberately stay on real time even when the clock is pinned, so that pinning it
857
+ cannot hang a process: **liveness bounds** (shutdown drain, request and config-fetch
858
+ timeouts) and **observational timestamps** (log, telemetry and support-bundle records).
859
+
734
860
  ### Job Corrections (User Task Listeners)
735
861
 
736
862
  When a job worker handles a [user task listener](https://docs.camunda.io/docs/components/concepts/user-task-listeners/), it can correct task properties (assignee, due date, candidate groups, etc.) by passing a `result` to `job.complete()`:
@@ -1215,67 +1341,288 @@ Notes:
1215
1341
  - Cancellation classification runs first so aborted fetches are never downgraded to generic network errors.
1216
1342
  - Abort is immediate and idempotent; underlying fetch is signalled.
1217
1343
 
1218
- ## Functional (fp-ts style) Surface (Opt-In Subpath)
1344
+ ## Effect Surface (Opt-In Subpath)
1219
1345
 
1220
- @experimental - this feature is not guaranteed to be tested or stable.
1346
+ The main entry stays Promise-based and pulls in **zero** Effect at runtime. Opt in to a
1347
+ first-class [Effect](https://effect.website) surface — a client whose every method returns an
1348
+ `Effect`, tagged domain errors, and Effect-native combinators — by importing the dedicated
1349
+ `./effect` subpath.
1221
1350
 
1222
- > **Peer dependency:** `fp-ts` is an optional peer dependency. If you use real `fp-ts` functions
1223
- > (e.g. `pipe`, `TE.match`) alongside this subpath, install it separately:
1351
+ > **Peer dependency:** `effect` is an **optional peer dependency** (Effect **v4**). The `./effect`
1352
+ > subpath requires it; install it alongside the SDK:
1224
1353
  > ```sh
1225
- > npm install fp-ts
1354
+ > npm install effect
1226
1355
  > ```
1227
- > The `/fp` subpath works without `fp-ts` installed — it exposes structurally-compatible
1228
- > `Either`/`TaskEither` shapes that interoperate with `fp-ts` but do not require it at runtime.
1229
-
1230
- The main entry stays minimal. To opt in to a TaskEither-style facade & helper combinators import from the dedicated subpath:
1356
+ > The main `.` entry never imports `effect`, so Promise-first users are never forced to adopt it.
1357
+ >
1358
+ > **Module resolution:** Effect v4 ships as an `exports`-map-only package (no legacy
1359
+ > `main`/`types`), so consuming the `./effect` types requires a modern TypeScript module
1360
+ > resolution — set `"moduleResolution": "bundler"` (or `"node16"`/`"nodenext"`) in your
1361
+ > `tsconfig.json`. The Promise-first `.` entry is unaffected.
1231
1362
 
1232
- <!-- snippet-exempt: uses SDK /fp subpath not available in examples project -->
1363
+ <!-- snippet-source: examples/effect.ts,examples/readme-imports.txt | regions: ReadmeEffectClientImport+ReadmeEffectClient -->
1233
1364
  ```ts
1365
+ import { Effect } from 'effect';
1234
1366
  import {
1235
- createCamundaFpClient,
1236
- retryTE,
1237
- withTimeoutTE,
1238
- eventuallyTE,
1239
- isLeft,
1240
- } from '@camunda8/orchestration-cluster-api/fp';
1241
-
1242
- const fp = createCamundaFpClient();
1243
- const deployTE = fp.deployResourcesFromFiles(['./bpmn/process.bpmn']);
1244
- const deployed = await deployTE();
1245
- if (isLeft(deployed)) throw deployed.left; // DomainError union
1367
+ createCamundaEffectClient,
1368
+ eventually,
1369
+ EventualConsistencyTimeout,
1370
+ } from '@camunda8/orchestration-cluster-api/effect';
1371
+
1372
+ const camunda = createCamundaEffectClient();
1373
+
1374
+ const program = Effect.gen(function* () {
1375
+ const deployment = yield* camunda.deployResourcesFromFiles(['./bpmn/process.bpmn']);
1376
+ const { processInstanceKey } = yield* camunda.createProcessInstance({
1377
+ processDefinitionKey: deployment.processes[0].processDefinitionKey,
1378
+ });
1379
+ // Poll on the Effect Clock until the instance is searchable, timing out deterministically.
1380
+ // waitUpToMs: 0 asks the SDK for the latest available state without its own wall-clock
1381
+ // wait, so the Effect `eventually` combinator owns the predicate + timeout horizon —
1382
+ // making the eventual-consistency wait deterministic under TestClock.
1383
+ const search = yield* eventually(
1384
+ camunda.searchProcessInstances(
1385
+ { filter: { processInstanceKey } },
1386
+ { consistency: { waitUpToMs: 0 } }
1387
+ ),
1388
+ (s) => s.items.some((i) => i.processInstanceKey === processInstanceKey),
1389
+ { waitUpTo: '30 seconds', interval: '750 millis' }
1390
+ );
1391
+ return { processInstanceKey, search };
1392
+ }).pipe(
1393
+ // Tagged errors → discriminate with catchTag / catchTags instead of a manual switch.
1394
+ Effect.catchTag('EventualConsistencyTimeout', (e: EventualConsistencyTimeout) =>
1395
+ Effect.logError(`Timed out: ${e.message}`).pipe(Effect.andThen(Effect.fail(e)))
1396
+ )
1397
+ );
1246
1398
 
1247
- // Chain with fp-ts (optional) – the returned thunks are structurally compatible with TaskEither
1248
- // import { pipe } from 'fp-ts/function'; import * as TE from 'fp-ts/TaskEither';
1399
+ const result = await Effect.runPromise(program);
1249
1400
  ```
1250
1401
 
1251
1402
  Why a subpath?
1252
1403
 
1253
- - Keeps base bundle lean for the 80% use case.
1254
- - No hard dependency on `fp-ts` at runtime; only structural types.
1255
- - Advanced users can compose with real `fp-ts` without pulling the effect model into the default import path.
1404
+ - Keeps the base bundle lean for the Promise-first 80% use case.
1405
+ - No dependency on `effect` at runtime unless you opt in; it is an **optional** peer.
1406
+ - Unlocks the Effect ecosystem (typed errors, `Schedule`, `Layer`/`Context`, `TestClock`).
1407
+
1408
+ Exports available from `.../effect`:
1409
+
1410
+ - `createCamundaEffectClient(options?)` – a `Proxy` client where every method returns
1411
+ `Effect.Effect<Awaited<R>, DomainError, never>`; the throwing client is reachable via `.inner`.
1412
+ - Tagged errors (`Data.TaggedError`): `CamundaValidationError`, `EventualConsistencyTimeout`,
1413
+ `HttpError`, `CamundaGenericError` — together the `DomainError` union. Discriminate with
1414
+ `Effect.catchTag` / `Effect.catchTags`.
1415
+ - Combinators: `retryWithBackoff` (`Effect.retry` + `Schedule.exponential` + jitter), `withTimeout`
1416
+ (`Effect.timeoutOrElse` with real interruption), `eventually` (a recursive `Effect.sleep` poll on
1417
+ the Effect `Clock`, timing out to `EventualConsistencyTimeout`).
1418
+ - Dependency injection: `CamundaEffect` (`Context.Service`) + `layer(options?)` (`Layer`) so worker /
1419
+ orchestration code composes via `Layer` and swaps a test double trivially.
1420
+ - Pagination: `.paginate(body, opts?)` on every `search*` method, returning an `EffectPaginator`
1421
+ (`pages()` / `items()` → `Stream`, `toArray()` → `Effect`). See below.
1422
+
1423
+ **Clock-class win:** `eventually` / `withTimeout` run on the Effect `Clock`, so `TestClock.adjust`
1424
+ advances eventual/timeout deterministically in tests — no real-clock burn. The Promise surface
1425
+ has the same property via [`createTestClock`](#deterministic-time-jobclock); the difference is
1426
+ that Effect gives you `TestClock` and the rest of the ecosystem for free.
1427
+
1428
+ ### Paginated Search as a `Stream`
1429
+
1430
+ Every `search*` operation on the Effect client carries the same `.paginate` helper the
1431
+ Promise client installs, re-expressed in Effect terms: `pages()` and `items()` are
1432
+ `Stream`s and `toArray()` is an `Effect`. Pages are fetched lazily as they are pulled,
1433
+ and interrupting the fiber cancels the in-flight page request.
1434
+
1435
+ <!-- snippet-source: examples/effect.ts,examples/readme-imports.txt | regions: ReadmeEffectPaginateImport+ReadmeEffectPaginate -->
1436
+ ```ts
1437
+ import { Effect, Stream } from 'effect';
1438
+ import { createCamundaEffectClient } from '@camunda8/orchestration-cluster-api/effect';
1439
+
1440
+ const camunda = createCamundaEffectClient();
1441
+
1442
+ // Walk every ACTIVE process instance, 100 per request, without ever holding more
1443
+ // than one page in memory. `Stream.take` stops pulling — and so stops fetching.
1444
+ const activeKeys = await Effect.runPromise(
1445
+ camunda.searchProcessInstances
1446
+ .paginate({ filter: { state: 'ACTIVE' }, page: { limit: 100 } })
1447
+ .items()
1448
+ .pipe(
1449
+ Stream.map((instance) => instance.processInstanceKey),
1450
+ Stream.take(500),
1451
+ Stream.runCollect
1452
+ )
1453
+ );
1454
+ ```
1256
1455
 
1257
- Exports available from `.../fp`:
1456
+ Options: `maxPages` (safety cap), `mode` (`auto` | `cursor` | `offset`), and `consistency`
1457
+ (forwarded to the first page only — once paging is under way an empty page is
1458
+ end-of-results, not a stale read).
1258
1459
 
1259
- - `createCamundaFpClient` – typed facade (methods return `() => Promise<Either<DomainError,T>>`).
1260
- - Type guards: `isLeft`, `isRight`.
1261
- - Error / type aliases: `DomainError`, `TaskEither`, `Either`, `Left`, `Right`, `Fpify`.
1262
- - Combinators: `retryTE`, `withTimeoutTE`, `eventuallyTE`.
1460
+ ### Effect Job Workers
1263
1461
 
1264
- DomainError union currently includes:
1462
+ The same subpath also exposes an **Effect-native job worker** — the long-running
1463
+ `activateJobs` → handle → `completeJob`/`failJob` loop, modelled as Effect. A handler is
1464
+ `(job) => Effect.Effect<CompleteVars, JobError, R>` with a **typed failure channel**: a
1465
+ `RetryableJobError` becomes `failJob` with `retries - 1` (plus an optional server-side backoff),
1466
+ and a `TerminalJobError` becomes `throwJobError` (caught by a BPMN error boundary, or an incident if
1467
+ uncaught). Success completes the job with the returned variables. It composes over the same
1468
+ activation/backpressure runtime the Promise worker uses — it does not reimplement activation.
1265
1469
 
1266
- - `CamundaValidationError`
1267
- - `EventualConsistencyTimeoutError`
1268
- - HTTP-like error objects (status/body/message) produced by transport
1269
- - Generic `Error`
1470
+ <!-- snippet-source: examples/effect.ts,examples/readme-imports.txt | regions: ReadmeEffectWorkerImport+ReadmeEffectWorker -->
1471
+ ```ts
1472
+ import { Effect, Schedule } from 'effect';
1473
+ import {
1474
+ createCamundaEffectWorker,
1475
+ layer,
1476
+ RetryableJobError,
1477
+ TerminalJobError,
1478
+ } from '@camunda8/orchestration-cluster-api/effect';
1479
+
1480
+ const program = Effect.gen(function* () {
1481
+ // Forked into the current Scope: interrupted (with a best-effort lease release) when
1482
+ // the scope closes. Let both type parameters infer — supplying only the completion-
1483
+ // variable type (`createCamundaEffectWorker<{ ok: boolean }>(…)`) makes TypeScript
1484
+ // fall back to the *default* for the handler's requirements (`R = never`) rather
1485
+ // than inferring it, so a handler with dependencies would stop compiling. See
1486
+ // "Injecting Services into a Handler".
1487
+ yield* createCamundaEffectWorker({
1488
+ type: 'payment-processing',
1489
+ maxJobsToActivate: 10, // activation batch size
1490
+ concurrency: 10, // max jobs handled in parallel (backpressure)
1491
+ pollInterval: '1 second', // between empty polls, on the Effect Clock
1492
+ // Optional: retry the handler in-process on a RetryableJobError before failing the job.
1493
+ handlerRetrySchedule: Schedule.spaced('2 seconds'),
1494
+ handler: (job) =>
1495
+ Effect.gen(function* () {
1496
+ if (!job.variables.amount) {
1497
+ // Terminal → raise a BPMN error / incident.
1498
+ return yield* Effect.fail(
1499
+ new TerminalJobError({ code: 'INVALID_INPUT', message: 'amount is required' })
1500
+ );
1501
+ }
1502
+ if (yield* isServiceDown()) {
1503
+ // Retryable → failJob(retries - 1) with a re-activation backoff.
1504
+ return yield* Effect.fail(
1505
+ new RetryableJobError({
1506
+ message: 'downstream unavailable',
1507
+ retryBackoff: '5 seconds',
1508
+ })
1509
+ );
1510
+ }
1511
+ return { ok: true }; // success → completeJob(variables)
1512
+ }),
1513
+ });
1514
+
1515
+ // ... the worker runs for the lifetime of this scope.
1516
+ yield* Effect.never;
1517
+ }).pipe(
1518
+ Effect.scoped,
1519
+ Effect.provide(layer()) // provides the `/effect` client the worker depends on
1520
+ );
1521
+
1522
+ void program;
1523
+ ```
1524
+
1525
+ Worker exports from `.../effect`:
1270
1526
 
1271
- You can refine left-channel typing later by mapping HTTP status codes or discriminator fields.
1527
+ - `createCamundaEffectWorker(config)` – forks the worker into the current `Scope` and returns a
1528
+ handle (`{ type, join, interrupt }`); provide the client `layer()` as its dependency.
1529
+ - `activateJobsStream(type, options)` – the lower-level `Stream.Stream<Job, DomainError, …>` of
1530
+ activated jobs, polling on the Effect `Clock`.
1531
+ - `workerLayer(config)` – a `Layer` that runs a worker for the layer's lifetime.
1532
+ - Tagged job failures: `RetryableJobError` (→ `failJob`), `TerminalJobError` (→ `throwJobError`),
1533
+ together the `JobError` channel.
1534
+
1535
+ **Clock-class win:** the activation poll interval and the handler-retry `Schedule` run on the Effect
1536
+ `Clock`, so `TestClock.adjust` bounds activation/retry timing in virtual time — the whole loop is
1537
+ deterministic in tests, with no real-clock burn. The Promise worker is equally drivable by pinning
1538
+ the client clock (see [Deterministic Time](#deterministic-time-jobclock)); what Effect adds here is
1539
+ `Schedule` composition over the retry policy.
1540
+
1541
+ ### Injecting Services into a Handler
1542
+
1543
+ A handler is `(job) => Effect.Effect<A, JobError, R>`, and `R` — whatever services the handler
1544
+ depends on — is threaded out through `createCamundaEffectWorker` / `workerLayer` into the worker's
1545
+ own requirements. So a handler's dependencies are provided, and swapped for mocks, exactly like
1546
+ any other `Layer`.
1547
+
1548
+ <!-- snippet-source: examples/effect.ts,examples/readme-imports.txt | regions: ReadmeEffectWorkerServicesImport+ReadmeEffectWorkerServices -->
1549
+ ```ts
1550
+ import { Context, Effect, Layer } from 'effect';
1551
+ import {
1552
+ CamundaEffect,
1553
+ type CamundaEffectClient,
1554
+ layer,
1555
+ workerLayer,
1556
+ } from '@camunda8/orchestration-cluster-api/effect';
1557
+
1558
+ // A service the handler depends on. Nothing about it is Camunda-specific — it is an
1559
+ // ordinary Effect service.
1560
+ class PaymentGateway extends Context.Service<
1561
+ PaymentGateway,
1562
+ { readonly charge: (amount: number) => Effect.Effect<string> }
1563
+ >()('PaymentGateway') {}
1564
+
1565
+ // The handler's requirements flow out through the worker's own requirements, so the
1566
+ // worker layer asks for `PaymentGateway` just like it asks for the Camunda client.
1567
+ const paymentWorker = workerLayer({
1568
+ type: 'payment-processing',
1569
+ handler: (job) =>
1570
+ Effect.gen(function* () {
1571
+ const gateway = yield* PaymentGateway;
1572
+ return { receipt: yield* gateway.charge(Number(job.variables.amount)) };
1573
+ }),
1574
+ });
1575
+ // paymentWorker: Layer<never, never, CamundaEffect | PaymentGateway>
1576
+
1577
+ // Production: the real gateway and a real client.
1578
+ const liveWorker = paymentWorker.pipe(
1579
+ Layer.provide(
1580
+ Layer.succeed(PaymentGateway, {
1581
+ charge: (amount) => Effect.succeed(`live-receipt-${amount}`),
1582
+ })
1583
+ ),
1584
+ Layer.provide(layer())
1585
+ );
1586
+
1587
+ // Tests: the same worker with *both* dependencies swapped. `CamundaEffect` is a service
1588
+ // too, so the broker is mocked exactly like the gateway — the worker runs end-to-end
1589
+ // with neither a payment provider nor a broker.
1590
+ const fakeClient = {
1591
+ activateJobs: () => Effect.succeed({ jobs: [] }),
1592
+ completeJob: () => Effect.void,
1593
+ failJob: () => Effect.void,
1594
+ throwJobError: () => Effect.void,
1595
+ } as unknown as CamundaEffectClient;
1596
+
1597
+ const mockedWorker = paymentWorker.pipe(
1598
+ Layer.provide(Layer.succeed(PaymentGateway, { charge: () => Effect.succeed('mock-receipt') })),
1599
+ Layer.provide(Layer.succeed(CamundaEffect, fakeClient))
1600
+ );
1601
+ ```
1602
+
1603
+ `Layer.succeed(CamundaEffect, fakeClient)` is what the SDK's own worker tests use; see
1604
+ [tests/effect-worker-di.test.ts](tests/effect-worker-di.test.ts) for worked examples that mock both
1605
+ dependencies and drive the loop to a `completeJob` / `failJob` under `TestClock`.
1606
+
1607
+ > **Gotcha — let both type parameters infer.** `createCamundaEffectWorker<A, R>` has `R = never`
1608
+ > as its default, and TypeScript does not infer a type parameter when only *some* are supplied.
1609
+ > So `createCamundaEffectWorker<{ ok: boolean }>({ ... })` pins `R` to `never`, and a handler with
1610
+ > dependencies fails to compile with an error pointing at the handler rather than at the missing
1611
+ > type argument:
1612
+ >
1613
+ > ```
1614
+ > Type 'PaymentGateway' is not assignable to type 'never'.
1615
+ > ```
1616
+ >
1617
+ > Omit both — `A` is inferred from the handler's success value — or supply both
1618
+ > (`createCamundaEffectWorker<{ receipt: string }, PaymentGateway>({ ... })`).
1272
1619
 
1273
1620
  ## Eventual Consistency Polling
1274
1621
 
1275
1622
  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.
1276
1623
 
1277
1624
  To consume eventual polling in a non‑throwing fashion set the client error mode before invoking an eventually consistent method:
1278
- 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.
1625
+ At present the canonical client operates in throwing mode. Non‑throwing adaptation (Result / Effect) is achieved via the functional wrappers rather than mutating the base client.
1279
1626
 
1280
1627
  ### Options
1281
1628
 
@@ -1554,47 +1901,85 @@ When to use:
1554
1901
  - Avoiding try/catch nesting in larger orchestration flows.
1555
1902
  - Converting to libraries expecting an Either/Result pattern.
1556
1903
 
1557
- ### fp-ts Adapter (TaskEither / Either) - EXPERIMENTAL
1904
+ ### Effect Adapter
1558
1905
 
1559
- _Note that this feature is experimental and subject to change._
1560
-
1561
- For projects using `fp-ts`, wrap the throwing client in a lazy `TaskEither` facade:
1906
+ For Effect-based projects, wrap the throwing client in an Effect-flavoured facade whose every method
1907
+ returns an `Effect` with a typed `DomainError` channel:
1562
1908
 
1563
- <!-- snippet-exempt: requires external fp-ts dependency -->
1909
+ <!-- snippet-exempt: requires optional effect peer dependency -->
1564
1910
  ```ts
1565
- import { createCamundaFpClient } from '@camunda8/orchestration-cluster-api/fp';
1566
- import { pipe } from 'fp-ts/function';
1567
- import * as TE from 'fp-ts/TaskEither';
1568
-
1569
- const fp = createCamundaFpClient();
1911
+ import { Effect } from 'effect';
1912
+ import { createCamundaEffectClient } from '@camunda8/orchestration-cluster-api/effect';
1570
1913
 
1571
- const deployTE = fp.createDeployment({ resources: [file] }); // TaskEither<unknown, ExtendedDeploymentResult>
1914
+ const camunda = createCamundaEffectClient();
1572
1915
 
1573
- pipe(
1574
- deployTE(), // invoke the task (returns Promise<Either>)
1575
- (then) => then // typical usage would use TE.match / TE.fold; shown expanded for clarity
1916
+ const deployment = await Effect.runPromise(
1917
+ camunda.createDeployment({ resources: [file] })
1576
1918
  );
1577
-
1578
- // With helpers
1579
- const task = fp.createDeployment({ resources: [file] });
1580
- const either = await task();
1581
- if (either._tag === 'Right') {
1582
- console.log(either.right.deployments.length);
1583
- } else {
1584
- console.error('Error', either.left);
1585
- }
1919
+ console.log(deployment.deployments.length);
1586
1920
  ```
1587
1921
 
1922
+ See [Effect Surface (Opt-In Subpath)](#effect-surface-opt-in-subpath) above for the full surface —
1923
+ tagged errors, `retryWithBackoff` / `withTimeout` / `eventually`, and `Layer`/`Context` DI.
1924
+
1588
1925
  Notes:
1589
1926
 
1590
- - No runtime dependency on `fp-ts`; adapter implements a minimal `Either` shape. Structural typing lets you lift into real `fp-ts` functions (`fromEither`, etc.).
1591
- - Each method becomes a function returning `() => Promise<Either<E,A>>` (a `TaskEither` shape). Invoke it later to execute.
1592
- - Cancellation: calling `.cancel()` on the original promise isn’t surfaced; if you need cancellation use the base client directly.
1593
- - For richer interop, you can map the returned factory to `TE.tryCatch` in userland.
1927
+ - `effect` is an **optional peer dependency**; only the `./effect` subpath imports it.
1928
+ - Each method returns `Effect.Effect<Awaited<R>, DomainError, never>`; the throwing client is reachable via `.inner`.
1929
+ - Failures are narrowed into tagged errors so you discriminate with `Effect.catchTag` / `catchTags`.
1594
1930
 
1595
1931
  ## Pagination
1596
1932
 
1597
- Search endpoints expose typed request bodies that include pagination fields. Provide the desired page object; auto‑pagination is not (yet) bundled.
1933
+ Every `search*` operation exposes a `.paginate(body, options?)` method that returns a lazy,
1934
+ cancelable async stream over **all** matching results. Cursors (or offsets) are advanced
1935
+ internally, so you never hand-write next-page bookkeeping.
1936
+
1937
+ <!-- snippet-source: examples/pagination.ts | regions: PaginateItems -->
1938
+
1939
+ ```ts
1940
+ // Stream every matching process instance across all pages. Cursors are advanced
1941
+ // internally; the loop stops when the server runs out of pages.
1942
+ async function everyActiveInstanceExample() {
1943
+ const camunda = createCamundaClient();
1944
+
1945
+ const stream = camunda.searchProcessInstances.paginate({
1946
+ filter: { state: 'ACTIVE' },
1947
+ page: { limit: 100 },
1948
+ });
1949
+
1950
+ for await (const instance of stream.items()) {
1951
+ console.log(instance.processInstanceKey);
1952
+ }
1953
+ }
1954
+ ```
1955
+
1956
+ Iterate a page at a time with `.pages()`, or drain a bounded result set into an array with
1957
+ `.toArray()`. Bound long streams with a `maxPages` cap and/or an `AbortSignal`:
1958
+
1959
+ <!-- snippet-source: examples/pagination.ts | regions: PaginateBounded -->
1960
+
1961
+ ```ts
1962
+ // Bound the stream with an AbortSignal and a hard page cap. A non-zero
1963
+ // `consistency` window is applied to the first page only, so freshly-written
1964
+ // data can be waited for without the terminal empty page timing out.
1965
+ async function boundedPaginationExample(processDefinitionId: ProcessDefinitionId) {
1966
+ const camunda = createCamundaClient();
1967
+ const ac = new AbortController();
1968
+ setTimeout(() => ac.abort(), 30_000);
1969
+
1970
+ const stream = camunda.searchProcessInstances.paginate(
1971
+ { filter: { processDefinitionId }, page: { limit: 100 } },
1972
+ { signal: ac.signal, maxPages: 10, consistency: { waitUpToMs: 5000 } }
1973
+ );
1974
+
1975
+ for await (const instance of stream.items()) {
1976
+ console.log(instance.processInstanceKey);
1977
+ }
1978
+ }
1979
+ ```
1980
+
1981
+ For advanced use, the low-level `nextPageRequest()` / `paginate()` primitives are also exported
1982
+ from the package entry point.
1598
1983
 
1599
1984
  ## Configuration Reference
1600
1985
 
@@ -1713,7 +2098,7 @@ Generate an HTML API reference site with TypeDoc (public entry points only):
1713
2098
  npm run docs:api
1714
2099
  ```
1715
2100
 
1716
- Output: static site in `docs/api` (open `docs/api/index.html` in a browser or serve the folder, e.g. `npx http-server docs/api`). Entry points: `src/index.ts`, `src/logger.ts`, `src/fp/index.ts`. Internal generated code, scripts, tests are excluded and private / protected members are filtered. Regenerate after changing public exports.
2101
+ Output: static site in `docs/api` (open `docs/api/index.html` in a browser or serve the folder, e.g. `npx http-server docs/api`). Entry points: `src/index.ts`, `src/logger.ts`, `src/effect/index.ts`. Internal generated code, scripts, tests are excluded and private / protected members are filtered. Regenerate after changing public exports.
1717
2102
 
1718
2103
  ## Contributing
1719
2104