@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.
- package/CHANGELOG.md +365 -0
- package/README.md +470 -85
- package/dist/{index-CBhZBupS.d.cts → CamundaClient-DBZ5xkfB.d.cts} +20579 -11210
- package/dist/{index-CMbPTSiX.d.ts → CamundaClient-DuZf_I-y.d.ts} +20579 -11210
- package/dist/{chunk-S3RXIYYE.js → chunk-GMNYLSVD.js} +12311 -7503
- package/dist/chunk-GMNYLSVD.js.map +1 -0
- package/dist/{chunk-KQ4UL2WX.js → chunk-LXWPMQOS.js} +1 -1
- package/dist/chunk-LXWPMQOS.js.map +1 -0
- package/dist/{fp → effect}/index.cjs +18556 -12212
- package/dist/effect/index.cjs.map +1 -0
- package/dist/effect/index.d.cts +315 -0
- package/dist/effect/index.d.ts +315 -0
- package/dist/effect/index.js +383 -0
- package/dist/effect/index.js.map +1 -0
- package/dist/index.cjs +18541 -12148
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +98 -6
- package/dist/index.d.ts +98 -6
- package/dist/index.js +217 -11
- package/dist/index.js.map +1 -1
- package/dist/logger.cjs.map +1 -1
- package/dist/logger.js +1 -1
- package/dist/threadWorkerEntry.cjs +86 -51
- package/dist/threadWorkerEntry.cjs.map +1 -1
- package/dist/threadWorkerEntry.js +86 -51
- package/dist/threadWorkerEntry.js.map +1 -1
- package/dist/zod.gen-PGIXLAJW.js +9523 -0
- package/dist/zod.gen-PGIXLAJW.js.map +1 -0
- package/package.json +48 -30
- package/dist/chunk-KQ4UL2WX.js.map +0 -1
- package/dist/chunk-S3RXIYYE.js.map +0 -1
- package/dist/fp/index.cjs.map +0 -1
- package/dist/fp/index.d.cts +0 -4
- package/dist/fp/index.d.ts +0 -4
- package/dist/fp/index.js +0 -23
- package/dist/fp/index.js.map +0 -1
- package/dist/zod.gen-UJLBQNEH.js +0 -8225
- 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
|
|
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
|
-
|
|
160
|
+
**→ See [MIGRATION.md](./MIGRATION.md) for the full guide**, including the complete list of affected fields.
|
|
161
161
|
|
|
162
|
-
|
|
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
|
-
##
|
|
1344
|
+
## Effect Surface (Opt-In Subpath)
|
|
1219
1345
|
|
|
1220
|
-
|
|
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:** `
|
|
1223
|
-
>
|
|
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
|
|
1354
|
+
> npm install effect
|
|
1226
1355
|
> ```
|
|
1227
|
-
> The
|
|
1228
|
-
>
|
|
1229
|
-
|
|
1230
|
-
|
|
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-
|
|
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
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
const
|
|
1243
|
-
const
|
|
1244
|
-
const
|
|
1245
|
-
|
|
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
|
-
|
|
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
|
|
1255
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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
|
-
|
|
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 /
|
|
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
|
-
###
|
|
1904
|
+
### Effect Adapter
|
|
1558
1905
|
|
|
1559
|
-
|
|
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
|
|
1909
|
+
<!-- snippet-exempt: requires optional effect peer dependency -->
|
|
1564
1910
|
```ts
|
|
1565
|
-
import {
|
|
1566
|
-
import {
|
|
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
|
|
1914
|
+
const camunda = createCamundaEffectClient();
|
|
1572
1915
|
|
|
1573
|
-
|
|
1574
|
-
|
|
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
|
-
-
|
|
1591
|
-
- Each method
|
|
1592
|
-
-
|
|
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
|
-
|
|
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/
|
|
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
|
|