@camunda8/orchestration-cluster-api 10.0.0-alpha.4 → 10.0.0-alpha.41

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
@@ -149,10 +149,51 @@ await camunda.createDeployment({
149
149
  });
150
150
  ```
151
151
 
152
- `TenantId.assumeExists()` validates the string against the tenant ID pattern and brands it at zero runtime cost. See [Branded Keys](#branded-keys) for more on this pattern.
152
+ `TenantId.assumeExists()` validates the string against the tenant ID pattern and returns a branded value. The branded value is just a string at runtime, but `assumeExists()` performs validation and can throw if the input is malformed. See [Branded Keys](#branded-keys) for more on this pattern.
153
153
 
154
154
  > **Tip**: If your tenant ID comes from a validated source (environment variable, config file), call `TenantId.assumeExists()` once at startup and pass the branded value throughout your application.
155
155
 
156
+ ## Migrating from 8.9
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.
159
+
160
+ ### New branded types
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
172
+
173
+ <!-- snippet-source: examples/readme.ts | regions: V9ToV10Migration -->
174
+
175
+ ```ts
176
+ // v9 — plain strings were accepted
177
+ // await camunda.assignRoleToGroup({
178
+ // roleId: 'developer',
179
+ // groupId: 'engineering',
180
+ // });
181
+
182
+ // v10 — use the branded type helpers at the boundary
183
+ await camunda.assignRoleToGroup({
184
+ roleId: RoleId.assumeExists('developer'),
185
+ groupId: GroupId.assumeExists('engineering'),
186
+ });
187
+ ```
188
+
189
+ 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
+
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
+
156
197
  ## Quick Start (Zero‑Config – Recommended)
157
198
 
158
199
  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.
@@ -576,6 +617,41 @@ Benchmark results against a single-node local cluster with multiple independent
576
617
 
577
618
  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.
578
619
 
620
+ ## Typed Variable Map (DTO-driven search)
621
+
622
+ `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.
623
+
624
+ The returned `VariableMap` offers two access modes:
625
+
626
+ - **Lenient** — `has(name)` / `get(name)` for defensive reads that never throw on missing variables.
627
+ - **Strict** — `validate()` returns a fully-typed object, or throws a `ZodError` when a required variable is missing or malformed.
628
+
629
+ 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.
630
+
631
+ <!-- snippet-source: examples/readme.ts | regions: ReadmeTypedVariables -->
632
+
633
+ ```ts
634
+ // The Zod schema is the DTO: its keys are the variable names to fetch, and its
635
+ // shape drives validation. Only these declared variables are queried, so memory
636
+ // stays bound by the DTO — not by the total number of variables on the instance.
637
+ const OrderVariables = z.object({
638
+ orderId: z.string(), // required
639
+ amount: z.number().optional(), // optional
640
+ });
641
+
642
+ const map = await camunda.searchVariablesAsDto(OrderVariables, { processInstanceKey });
643
+
644
+ // Lenient access: defensive reads that never throw on missing variables.
645
+ if (map.has('amount')) {
646
+ console.log('Amount:', map.get('amount'));
647
+ }
648
+
649
+ // Strict access: returns a fully-typed object, or throws a ZodError when a
650
+ // required variable is missing or malformed.
651
+ const order = map.validate(); // { orderId: string; amount?: number }
652
+ console.log('Order:', order.orderId);
653
+ ```
654
+
579
655
  ## Job Workers (Polling API)
580
656
 
581
657
  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.
@@ -681,6 +757,7 @@ Example patterns:
681
757
  return job.complete({ variables: { processed: true } });
682
758
 
683
759
  // GOOD: No-arg completion example, sentinel stored for ultimate return
760
+ // biome-ignore lint/correctness/noUnreachable: intentional — showing multiple completion patterns
684
761
  const ack = await job.complete();
685
762
  // ...
686
763
  return ack;
@@ -1173,67 +1250,284 @@ Notes:
1173
1250
  - Cancellation classification runs first so aborted fetches are never downgraded to generic network errors.
1174
1251
  - Abort is immediate and idempotent; underlying fetch is signalled.
1175
1252
 
1176
- ## Functional (fp-ts style) Surface (Opt-In Subpath)
1253
+ ## Effect Surface (Opt-In Subpath)
1177
1254
 
1178
- @experimental - this feature is not guaranteed to be tested or stable.
1255
+ The main entry stays Promise-based and pulls in **zero** Effect at runtime. Opt in to a
1256
+ first-class [Effect](https://effect.website) surface — a client whose every method returns an
1257
+ `Effect`, tagged domain errors, and Effect-native combinators — by importing the dedicated
1258
+ `./effect` subpath.
1179
1259
 
1180
- > **Peer dependency:** `fp-ts` is an optional peer dependency. If you use real `fp-ts` functions
1181
- > (e.g. `pipe`, `TE.match`) alongside this subpath, install it separately:
1260
+ > **Peer dependency:** `effect` is an **optional peer dependency** (Effect **v4**). The `./effect`
1261
+ > subpath requires it; install it alongside the SDK:
1182
1262
  > ```sh
1183
- > npm install fp-ts
1263
+ > npm install effect
1184
1264
  > ```
1185
- > The `/fp` subpath works without `fp-ts` installed — it exposes structurally-compatible
1186
- > `Either`/`TaskEither` shapes that interoperate with `fp-ts` but do not require it at runtime.
1187
-
1188
- The main entry stays minimal. To opt in to a TaskEither-style facade & helper combinators import from the dedicated subpath:
1265
+ > The main `.` entry never imports `effect`, so Promise-first users are never forced to adopt it.
1266
+ >
1267
+ > **Module resolution:** Effect v4 ships as an `exports`-map-only package (no legacy
1268
+ > `main`/`types`), so consuming the `./effect` types requires a modern TypeScript module
1269
+ > resolution — set `"moduleResolution": "bundler"` (or `"node16"`/`"nodenext"`) in your
1270
+ > `tsconfig.json`. The Promise-first `.` entry is unaffected.
1189
1271
 
1190
- <!-- snippet-exempt: uses SDK /fp subpath not available in examples project -->
1272
+ <!-- snippet-source: examples/effect.ts,examples/readme-imports.txt | regions: ReadmeEffectClientImport+ReadmeEffectClient -->
1191
1273
  ```ts
1274
+ import { Effect } from 'effect';
1192
1275
  import {
1193
- createCamundaFpClient,
1194
- retryTE,
1195
- withTimeoutTE,
1196
- eventuallyTE,
1197
- isLeft,
1198
- } from '@camunda8/orchestration-cluster-api/fp';
1199
-
1200
- const fp = createCamundaFpClient();
1201
- const deployTE = fp.deployResourcesFromFiles(['./bpmn/process.bpmn']);
1202
- const deployed = await deployTE();
1203
- if (isLeft(deployed)) throw deployed.left; // DomainError union
1276
+ createCamundaEffectClient,
1277
+ eventually,
1278
+ EventualConsistencyTimeout,
1279
+ } from '@camunda8/orchestration-cluster-api/effect';
1280
+
1281
+ const camunda = createCamundaEffectClient();
1282
+
1283
+ const program = Effect.gen(function* () {
1284
+ const deployment = yield* camunda.deployResourcesFromFiles(['./bpmn/process.bpmn']);
1285
+ const { processInstanceKey } = yield* camunda.createProcessInstance({
1286
+ processDefinitionKey: deployment.processes[0].processDefinitionKey,
1287
+ });
1288
+ // Poll on the Effect Clock until the instance is searchable, timing out deterministically.
1289
+ // waitUpToMs: 0 asks the SDK for the latest available state without its own wall-clock
1290
+ // wait, so the Effect `eventually` combinator owns the predicate + timeout horizon —
1291
+ // making the eventual-consistency wait deterministic under TestClock.
1292
+ const search = yield* eventually(
1293
+ camunda.searchProcessInstances(
1294
+ { filter: { processInstanceKey } },
1295
+ { consistency: { waitUpToMs: 0 } }
1296
+ ),
1297
+ (s) => s.items.some((i) => i.processInstanceKey === processInstanceKey),
1298
+ { waitUpTo: '30 seconds', interval: '750 millis' }
1299
+ );
1300
+ return { processInstanceKey, search };
1301
+ }).pipe(
1302
+ // Tagged errors → discriminate with catchTag / catchTags instead of a manual switch.
1303
+ Effect.catchTag('EventualConsistencyTimeout', (e: EventualConsistencyTimeout) =>
1304
+ Effect.logError(`Timed out: ${e.message}`).pipe(Effect.andThen(Effect.fail(e)))
1305
+ )
1306
+ );
1204
1307
 
1205
- // Chain with fp-ts (optional) – the returned thunks are structurally compatible with TaskEither
1206
- // import { pipe } from 'fp-ts/function'; import * as TE from 'fp-ts/TaskEither';
1308
+ const result = await Effect.runPromise(program);
1207
1309
  ```
1208
1310
 
1209
1311
  Why a subpath?
1210
1312
 
1211
- - Keeps base bundle lean for the 80% use case.
1212
- - No hard dependency on `fp-ts` at runtime; only structural types.
1213
- - Advanced users can compose with real `fp-ts` without pulling the effect model into the default import path.
1313
+ - Keeps the base bundle lean for the Promise-first 80% use case.
1314
+ - No dependency on `effect` at runtime unless you opt in; it is an **optional** peer.
1315
+ - Unlocks the Effect ecosystem (typed errors, `Schedule`, `Layer`/`Context`, `TestClock`).
1214
1316
 
1215
- Exports available from `.../fp`:
1317
+ Exports available from `.../effect`:
1216
1318
 
1217
- - `createCamundaFpClient` – typed facade (methods return `() => Promise<Either<DomainError,T>>`).
1218
- - Type guards: `isLeft`, `isRight`.
1219
- - Error / type aliases: `DomainError`, `TaskEither`, `Either`, `Left`, `Right`, `Fpify`.
1220
- - Combinators: `retryTE`, `withTimeoutTE`, `eventuallyTE`.
1319
+ - `createCamundaEffectClient(options?)` – a `Proxy` client where every method returns
1320
+ `Effect.Effect<Awaited<R>, DomainError, never>`; the throwing client is reachable via `.inner`.
1321
+ - Tagged errors (`Data.TaggedError`): `CamundaValidationError`, `EventualConsistencyTimeout`,
1322
+ `HttpError`, `CamundaGenericError` — together the `DomainError` union. Discriminate with
1323
+ `Effect.catchTag` / `Effect.catchTags`.
1324
+ - Combinators: `retryWithBackoff` (`Effect.retry` + `Schedule.exponential` + jitter), `withTimeout`
1325
+ (`Effect.timeoutOrElse` with real interruption), `eventually` (a recursive `Effect.sleep` poll on
1326
+ the Effect `Clock`, timing out to `EventualConsistencyTimeout`).
1327
+ - Dependency injection: `CamundaEffect` (`Context.Service`) + `layer(options?)` (`Layer`) so worker /
1328
+ orchestration code composes via `Layer` and swaps a test double trivially.
1329
+ - Pagination: `.paginate(body, opts?)` on every `search*` method, returning an `EffectPaginator`
1330
+ (`pages()` / `items()` → `Stream`, `toArray()` → `Effect`). See below.
1221
1331
 
1222
- DomainError union currently includes:
1332
+ **Clock-class win:** `eventually` / `withTimeout` run on the Effect `Clock`, so `TestClock.adjust`
1333
+ advances eventual/timeout deterministically in tests — no real-clock burn.
1223
1334
 
1224
- - `CamundaValidationError`
1225
- - `EventualConsistencyTimeoutError`
1226
- - HTTP-like error objects (status/body/message) produced by transport
1227
- - Generic `Error`
1335
+ ### Paginated Search as a `Stream`
1336
+
1337
+ Every `search*` operation on the Effect client carries the same `.paginate` helper the
1338
+ Promise client installs, re-expressed in Effect terms: `pages()` and `items()` are
1339
+ `Stream`s and `toArray()` is an `Effect`. Pages are fetched lazily as they are pulled,
1340
+ and interrupting the fiber cancels the in-flight page request.
1228
1341
 
1229
- You can refine left-channel typing later by mapping HTTP status codes or discriminator fields.
1342
+ <!-- snippet-source: examples/effect.ts,examples/readme-imports.txt | regions: ReadmeEffectPaginateImport+ReadmeEffectPaginate -->
1343
+ ```ts
1344
+ import { Effect, Stream } from 'effect';
1345
+ import { createCamundaEffectClient } from '@camunda8/orchestration-cluster-api/effect';
1346
+
1347
+ const camunda = createCamundaEffectClient();
1348
+
1349
+ // Walk every ACTIVE process instance, 100 per request, without ever holding more
1350
+ // than one page in memory. `Stream.take` stops pulling — and so stops fetching.
1351
+ const activeKeys = await Effect.runPromise(
1352
+ camunda.searchProcessInstances
1353
+ .paginate({ filter: { state: 'ACTIVE' }, page: { limit: 100 } })
1354
+ .items()
1355
+ .pipe(
1356
+ Stream.map((instance) => instance.processInstanceKey),
1357
+ Stream.take(500),
1358
+ Stream.runCollect
1359
+ )
1360
+ );
1361
+ ```
1362
+
1363
+ Options: `maxPages` (safety cap), `mode` (`auto` | `cursor` | `offset`), and `consistency`
1364
+ (forwarded to the first page only — once paging is under way an empty page is
1365
+ end-of-results, not a stale read).
1366
+
1367
+ ### Effect Job Workers
1368
+
1369
+ The same subpath also exposes an **Effect-native job worker** — the long-running
1370
+ `activateJobs` → handle → `completeJob`/`failJob` loop, modelled as Effect. A handler is
1371
+ `(job) => Effect.Effect<CompleteVars, JobError, R>` with a **typed failure channel**: a
1372
+ `RetryableJobError` becomes `failJob` with `retries - 1` (plus an optional server-side backoff),
1373
+ and a `TerminalJobError` becomes `throwJobError` (caught by a BPMN error boundary, or an incident if
1374
+ uncaught). Success completes the job with the returned variables. It composes over the same
1375
+ activation/backpressure runtime the Promise worker uses — it does not reimplement activation.
1376
+
1377
+ <!-- snippet-source: examples/effect.ts,examples/readme-imports.txt | regions: ReadmeEffectWorkerImport+ReadmeEffectWorker -->
1378
+ ```ts
1379
+ import { Effect, Schedule } from 'effect';
1380
+ import {
1381
+ createCamundaEffectWorker,
1382
+ layer,
1383
+ RetryableJobError,
1384
+ TerminalJobError,
1385
+ } from '@camunda8/orchestration-cluster-api/effect';
1386
+
1387
+ const program = Effect.gen(function* () {
1388
+ // Forked into the current Scope: interrupted (with a best-effort lease release) when
1389
+ // the scope closes. Let both type parameters infer — supplying only the completion-
1390
+ // variable type (`createCamundaEffectWorker<{ ok: boolean }>(…)`) makes TypeScript
1391
+ // fall back to the *default* for the handler's requirements (`R = never`) rather
1392
+ // than inferring it, so a handler with dependencies would stop compiling. See
1393
+ // "Injecting Services into a Handler".
1394
+ yield* createCamundaEffectWorker({
1395
+ type: 'payment-processing',
1396
+ maxJobsToActivate: 10, // activation batch size
1397
+ concurrency: 10, // max jobs handled in parallel (backpressure)
1398
+ pollInterval: '1 second', // between empty polls, on the Effect Clock
1399
+ // Optional: retry the handler in-process on a RetryableJobError before failing the job.
1400
+ handlerRetrySchedule: Schedule.spaced('2 seconds'),
1401
+ handler: (job) =>
1402
+ Effect.gen(function* () {
1403
+ if (!job.variables.amount) {
1404
+ // Terminal → raise a BPMN error / incident.
1405
+ return yield* Effect.fail(
1406
+ new TerminalJobError({ code: 'INVALID_INPUT', message: 'amount is required' })
1407
+ );
1408
+ }
1409
+ if (yield* isServiceDown()) {
1410
+ // Retryable → failJob(retries - 1) with a re-activation backoff.
1411
+ return yield* Effect.fail(
1412
+ new RetryableJobError({
1413
+ message: 'downstream unavailable',
1414
+ retryBackoff: '5 seconds',
1415
+ })
1416
+ );
1417
+ }
1418
+ return { ok: true }; // success → completeJob(variables)
1419
+ }),
1420
+ });
1421
+
1422
+ // ... the worker runs for the lifetime of this scope.
1423
+ yield* Effect.never;
1424
+ }).pipe(
1425
+ Effect.scoped,
1426
+ Effect.provide(layer()) // provides the `/effect` client the worker depends on
1427
+ );
1428
+
1429
+ void program;
1430
+ ```
1431
+
1432
+ Worker exports from `.../effect`:
1433
+
1434
+ - `createCamundaEffectWorker(config)` – forks the worker into the current `Scope` and returns a
1435
+ handle (`{ type, join, interrupt }`); provide the client `layer()` as its dependency.
1436
+ - `activateJobsStream(type, options)` – the lower-level `Stream.Stream<Job, DomainError, …>` of
1437
+ activated jobs, polling on the Effect `Clock`.
1438
+ - `workerLayer(config)` – a `Layer` that runs a worker for the layer's lifetime.
1439
+ - Tagged job failures: `RetryableJobError` (→ `failJob`), `TerminalJobError` (→ `throwJobError`),
1440
+ together the `JobError` channel.
1441
+
1442
+ **Clock-class win:** the activation poll interval and the handler-retry `Schedule` run on the Effect
1443
+ `Clock`, so `TestClock.adjust` bounds activation/retry timing in virtual time — the whole loop is
1444
+ deterministic in tests, with no real-clock burn.
1445
+
1446
+ ### Injecting Services into a Handler
1447
+
1448
+ A handler is `(job) => Effect.Effect<A, JobError, R>`, and `R` — whatever services the handler
1449
+ depends on — is threaded out through `createCamundaEffectWorker` / `workerLayer` into the worker's
1450
+ own requirements. So a handler's dependencies are provided, and swapped for mocks, exactly like
1451
+ any other `Layer`.
1452
+
1453
+ <!-- snippet-source: examples/effect.ts,examples/readme-imports.txt | regions: ReadmeEffectWorkerServicesImport+ReadmeEffectWorkerServices -->
1454
+ ```ts
1455
+ import { Context, Effect, Layer } from 'effect';
1456
+ import {
1457
+ CamundaEffect,
1458
+ type CamundaEffectClient,
1459
+ layer,
1460
+ workerLayer,
1461
+ } from '@camunda8/orchestration-cluster-api/effect';
1462
+
1463
+ // A service the handler depends on. Nothing about it is Camunda-specific — it is an
1464
+ // ordinary Effect service.
1465
+ class PaymentGateway extends Context.Service<
1466
+ PaymentGateway,
1467
+ { readonly charge: (amount: number) => Effect.Effect<string> }
1468
+ >()('PaymentGateway') {}
1469
+
1470
+ // The handler's requirements flow out through the worker's own requirements, so the
1471
+ // worker layer asks for `PaymentGateway` just like it asks for the Camunda client.
1472
+ const paymentWorker = workerLayer({
1473
+ type: 'payment-processing',
1474
+ handler: (job) =>
1475
+ Effect.gen(function* () {
1476
+ const gateway = yield* PaymentGateway;
1477
+ return { receipt: yield* gateway.charge(Number(job.variables.amount)) };
1478
+ }),
1479
+ });
1480
+ // paymentWorker: Layer<never, never, CamundaEffect | PaymentGateway>
1481
+
1482
+ // Production: the real gateway and a real client.
1483
+ const liveWorker = paymentWorker.pipe(
1484
+ Layer.provide(
1485
+ Layer.succeed(PaymentGateway, {
1486
+ charge: (amount) => Effect.succeed(`live-receipt-${amount}`),
1487
+ })
1488
+ ),
1489
+ Layer.provide(layer())
1490
+ );
1491
+
1492
+ // Tests: the same worker with *both* dependencies swapped. `CamundaEffect` is a service
1493
+ // too, so the broker is mocked exactly like the gateway — the worker runs end-to-end
1494
+ // with neither a payment provider nor a broker.
1495
+ const fakeClient = {
1496
+ activateJobs: () => Effect.succeed({ jobs: [] }),
1497
+ completeJob: () => Effect.void,
1498
+ failJob: () => Effect.void,
1499
+ throwJobError: () => Effect.void,
1500
+ } as unknown as CamundaEffectClient;
1501
+
1502
+ const mockedWorker = paymentWorker.pipe(
1503
+ Layer.provide(Layer.succeed(PaymentGateway, { charge: () => Effect.succeed('mock-receipt') })),
1504
+ Layer.provide(Layer.succeed(CamundaEffect, fakeClient))
1505
+ );
1506
+ ```
1507
+
1508
+ `Layer.succeed(CamundaEffect, fakeClient)` is what the SDK's own worker tests use; see
1509
+ [tests/effect-worker-di.test.ts](tests/effect-worker-di.test.ts) for worked examples that mock both
1510
+ dependencies and drive the loop to a `completeJob` / `failJob` under `TestClock`.
1511
+
1512
+ > **Gotcha — let both type parameters infer.** `createCamundaEffectWorker<A, R>` has `R = never`
1513
+ > as its default, and TypeScript does not infer a type parameter when only *some* are supplied.
1514
+ > So `createCamundaEffectWorker<{ ok: boolean }>({ ... })` pins `R` to `never`, and a handler with
1515
+ > dependencies fails to compile with an error pointing at the handler rather than at the missing
1516
+ > type argument:
1517
+ >
1518
+ > ```
1519
+ > Type 'PaymentGateway' is not assignable to type 'never'.
1520
+ > ```
1521
+ >
1522
+ > Omit both — `A` is inferred from the handler's success value — or supply both
1523
+ > (`createCamundaEffectWorker<{ receipt: string }, PaymentGateway>({ ... })`).
1230
1524
 
1231
1525
  ## Eventual Consistency Polling
1232
1526
 
1233
1527
  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.
1234
1528
 
1235
1529
  To consume eventual polling in a non‑throwing fashion set the client error mode before invoking an eventually consistent method:
1236
- 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.
1530
+ 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.
1237
1531
 
1238
1532
  ### Options
1239
1533
 
@@ -1512,47 +1806,85 @@ When to use:
1512
1806
  - Avoiding try/catch nesting in larger orchestration flows.
1513
1807
  - Converting to libraries expecting an Either/Result pattern.
1514
1808
 
1515
- ### fp-ts Adapter (TaskEither / Either) - EXPERIMENTAL
1809
+ ### Effect Adapter
1516
1810
 
1517
- _Note that this feature is experimental and subject to change._
1518
-
1519
- For projects using `fp-ts`, wrap the throwing client in a lazy `TaskEither` facade:
1811
+ For Effect-based projects, wrap the throwing client in an Effect-flavoured facade whose every method
1812
+ returns an `Effect` with a typed `DomainError` channel:
1520
1813
 
1521
- <!-- snippet-exempt: requires external fp-ts dependency -->
1814
+ <!-- snippet-exempt: requires optional effect peer dependency -->
1522
1815
  ```ts
1523
- import { createCamundaFpClient } from '@camunda8/orchestration-cluster-api/fp';
1524
- import { pipe } from 'fp-ts/function';
1525
- import * as TE from 'fp-ts/TaskEither';
1526
-
1527
- const fp = createCamundaFpClient();
1816
+ import { Effect } from 'effect';
1817
+ import { createCamundaEffectClient } from '@camunda8/orchestration-cluster-api/effect';
1528
1818
 
1529
- const deployTE = fp.createDeployment({ resources: [file] }); // TaskEither<unknown, ExtendedDeploymentResult>
1819
+ const camunda = createCamundaEffectClient();
1530
1820
 
1531
- pipe(
1532
- deployTE(), // invoke the task (returns Promise<Either>)
1533
- (then) => then // typical usage would use TE.match / TE.fold; shown expanded for clarity
1821
+ const deployment = await Effect.runPromise(
1822
+ camunda.createDeployment({ resources: [file] })
1534
1823
  );
1535
-
1536
- // With helpers
1537
- const task = fp.createDeployment({ resources: [file] });
1538
- const either = await task();
1539
- if (either._tag === 'Right') {
1540
- console.log(either.right.deployments.length);
1541
- } else {
1542
- console.error('Error', either.left);
1543
- }
1824
+ console.log(deployment.deployments.length);
1544
1825
  ```
1545
1826
 
1827
+ See [Effect Surface (Opt-In Subpath)](#effect-surface-opt-in-subpath) above for the full surface —
1828
+ tagged errors, `retryWithBackoff` / `withTimeout` / `eventually`, and `Layer`/`Context` DI.
1829
+
1546
1830
  Notes:
1547
1831
 
1548
- - No runtime dependency on `fp-ts`; adapter implements a minimal `Either` shape. Structural typing lets you lift into real `fp-ts` functions (`fromEither`, etc.).
1549
- - Each method becomes a function returning `() => Promise<Either<E,A>>` (a `TaskEither` shape). Invoke it later to execute.
1550
- - Cancellation: calling `.cancel()` on the original promise isn’t surfaced; if you need cancellation use the base client directly.
1551
- - For richer interop, you can map the returned factory to `TE.tryCatch` in userland.
1832
+ - `effect` is an **optional peer dependency**; only the `./effect` subpath imports it.
1833
+ - Each method returns `Effect.Effect<Awaited<R>, DomainError, never>`; the throwing client is reachable via `.inner`.
1834
+ - Failures are narrowed into tagged errors so you discriminate with `Effect.catchTag` / `catchTags`.
1552
1835
 
1553
1836
  ## Pagination
1554
1837
 
1555
- Search endpoints expose typed request bodies that include pagination fields. Provide the desired page object; auto‑pagination is not (yet) bundled.
1838
+ Every `search*` operation exposes a `.paginate(body, options?)` method that returns a lazy,
1839
+ cancelable async stream over **all** matching results. Cursors (or offsets) are advanced
1840
+ internally, so you never hand-write next-page bookkeeping.
1841
+
1842
+ <!-- snippet-source: examples/pagination.ts | regions: PaginateItems -->
1843
+
1844
+ ```ts
1845
+ // Stream every matching process instance across all pages. Cursors are advanced
1846
+ // internally; the loop stops when the server runs out of pages.
1847
+ async function everyActiveInstanceExample() {
1848
+ const camunda = createCamundaClient();
1849
+
1850
+ const stream = camunda.searchProcessInstances.paginate({
1851
+ filter: { state: 'ACTIVE' },
1852
+ page: { limit: 100 },
1853
+ });
1854
+
1855
+ for await (const instance of stream.items()) {
1856
+ console.log(instance.processInstanceKey);
1857
+ }
1858
+ }
1859
+ ```
1860
+
1861
+ Iterate a page at a time with `.pages()`, or drain a bounded result set into an array with
1862
+ `.toArray()`. Bound long streams with a `maxPages` cap and/or an `AbortSignal`:
1863
+
1864
+ <!-- snippet-source: examples/pagination.ts | regions: PaginateBounded -->
1865
+
1866
+ ```ts
1867
+ // Bound the stream with an AbortSignal and a hard page cap. A non-zero
1868
+ // `consistency` window is applied to the first page only, so freshly-written
1869
+ // data can be waited for without the terminal empty page timing out.
1870
+ async function boundedPaginationExample(processDefinitionId: ProcessDefinitionId) {
1871
+ const camunda = createCamundaClient();
1872
+ const ac = new AbortController();
1873
+ setTimeout(() => ac.abort(), 30_000);
1874
+
1875
+ const stream = camunda.searchProcessInstances.paginate(
1876
+ { filter: { processDefinitionId }, page: { limit: 100 } },
1877
+ { signal: ac.signal, maxPages: 10, consistency: { waitUpToMs: 5000 } }
1878
+ );
1879
+
1880
+ for await (const instance of stream.items()) {
1881
+ console.log(instance.processInstanceKey);
1882
+ }
1883
+ }
1884
+ ```
1885
+
1886
+ For advanced use, the low-level `nextPageRequest()` / `paginate()` primitives are also exported
1887
+ from the package entry point.
1556
1888
 
1557
1889
  ## Configuration Reference
1558
1890
 
@@ -1671,7 +2003,7 @@ Generate an HTML API reference site with TypeDoc (public entry points only):
1671
2003
  npm run docs:api
1672
2004
  ```
1673
2005
 
1674
- 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.
2006
+ 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.
1675
2007
 
1676
2008
  ## Contributing
1677
2009