@nanobpm/bojtos-react 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,9 @@ React binding for the **Bojtos** in-browser BPMN demo framework
6
6
 
7
7
  - **`useBojtos({ bpmn })`** — owns the engine session and the reactive
8
8
  `snapshot` / `events` / `processIds` state, and exposes the engine commands
9
- (`createInstance`, `completeJob`, `failJob`, `advanceTime`, `reset`).
9
+ (`createInstance`, `completeJob`, `failJob`, `advanceTime`, `reset`). Pass
10
+ `variant: "readmodel"` to also thread the gateway's read channel through the
11
+ hook — see [Read model](#read-model) below.
10
12
  - **`<BpmnRuntimeView xml activeIds incidentIds />`** — the live diagram: it
11
13
  imports the XML once and updates token (`nano-active`) / incident
12
14
  (`nano-incident`) markers in place, so zoom/scroll survive stepping.
@@ -71,6 +73,48 @@ For an agentic run, emit `TraceEntry` lines from your handlers (with the additiv
71
73
  unchanged. It never imports `bpmn-js`, so importing it alone won't pull the
72
74
  diagram bundle in.
73
75
 
76
+ ## Read model
77
+
78
+ By default `useBojtos` loads the **lean** engine (primary state only). Pass
79
+ `variant: "readmodel"` to load the read-model engine variant instead, which adds
80
+ the gateway's Camunda-parity REST read channel. The returned controls then widen
81
+ from `BojtosControls` to `ReadModelBojtosControls`, exposing five pull queries —
82
+ `searchUserTasks`, `searchProcessInstances`, `searchVariables`, `getFormByKey`,
83
+ `getResourceByKey` — plus a `readModelVersion` counter. The heavier read-model
84
+ binary code-splits in only for `"readmodel"` hooks; a lean hook never downloads
85
+ it.
86
+
87
+ The read queries are **pull** projections of the read model, not part of the
88
+ command→`snapshot` push loop, so they don't land in state on their own. Each read
89
+ method returns `null` until the engine is ready (and on a lean hook), and
90
+ `readModelVersion` bumps after every command / worker round / deploy / reset —
91
+ i.e. whenever the read model may have moved. Make a query reactive with the
92
+ ready-made `useReadModel` selector, which re-runs it keyed on `readModelVersion`:
93
+
94
+ ```tsx
95
+ import { useBojtos, useReadModel } from "@nanobpm/bojtos-react";
96
+
97
+ function ReviewInbox({ bpmn }: { bpmn: string }) {
98
+ const run = useBojtos({ bpmn, variant: "readmodel" });
99
+ // Re-runs after every command / round; `?? []` covers the not-ready null.
100
+ const openTasks = useReadModel(
101
+ run,
102
+ (rm) => rm.searchUserTasks('{"state":"CREATED"}')?.items ?? [],
103
+ );
104
+ return (
105
+ <ul>
106
+ {openTasks.map((t) => (
107
+ <li key={t.userTaskKey}>{t.elementId}</li>
108
+ ))}
109
+ </ul>
110
+ );
111
+ }
112
+ ```
113
+
114
+ You can also call the read methods imperatively (e.g. from an event handler)
115
+ whenever you want a one-off answer — `useReadModel` is just the reactive wrapper
116
+ over the same `readModelVersion` signal.
117
+
74
118
  ## Peer requirements
75
119
 
76
120
  `react` and `bpmn-js` are peer dependencies (the consumer already has them). The
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- export { useBojtos, type UseBojtosOptions, type BojtosControls, type BojtosPhase, } from "./useBojtos.js";
1
+ export { useBojtos, useReadModel, type UseBojtosOptions, type BojtosControls, type ReadModelBojtosControls, type BojtosPhase, } from "./useBojtos.js";
2
2
  export { Bojtos, type BojtosProps, type TraceEvent } from "./Bojtos.js";
3
3
  export { OrderFulfillmentDemo, ORDER_FULFILLMENT_BPMN, orderFulfillmentWorkers, } from "./examples/orderFulfillment.js";
4
4
  export { BpmnRuntimeView, type BpmnRuntimeViewProps, } from "./BpmnRuntimeView.js";
5
5
  export { TraceTimeline, type TraceTimelineProps } from "./TraceTimeline.js";
6
- export { describeRunState, markerKey, bpmnKey, resourceList, capEvents, } from "./runState.js";
6
+ export { describeRunState, markerKey, bpmnKey, resourceList, capEvents, resolveVariant, selectReadModel, } from "./runState.js";
7
7
  export { JobFailure, settleReason, unhandledJobTypes, buildTraceItems, isTraceTurnGroup, foldEngineEvents, traceEntriesToRows, type JobHandler, type JobResult, type AgentHandler, type DispatchOptions, type DispatchResult, type RoundResult, type SettleReason, } from "@nanobpm/bojtos-kit";
8
- export type { BojtosSession, Snapshot, InstanceDto, JobDto, ActivatedJob, IncidentDto, TimerDto, UserTaskDto, MessageSubscriptionDto, SignalSubscriptionDto, ElementStatDto, SequenceFlowDto, DecisionInstanceDto, ActiveEl, ActivateInstruction, AgentActivation, AgentResult, WasmEvent, TraceRowKind, TraceEntry, TraceRow, TraceTurnGroup, TraceItem, TraceAdapter, } from "@nanobpm/bojtos-kit";
8
+ export type { BojtosSession, ReadModelBojtosSession, EngineVariant, Snapshot, InstanceDto, JobDto, ActivatedJob, IncidentDto, TimerDto, UserTaskDto, MessageSubscriptionDto, SignalSubscriptionDto, ElementStatDto, SequenceFlowDto, DecisionInstanceDto, ActiveEl, ActivateInstruction, AgentActivation, AgentResult, WasmEvent, TraceRowKind, TraceEntry, TraceRow, TraceTurnGroup, TraceItem, TraceAdapter, UserTaskSearchQueryResult, UserTaskResult, ProcessInstanceSearchQueryResult, ProcessInstanceResult, VariableSearchQueryResult, VariableResult, FormResult, ResourceResult, SearchQueryResponse, SearchQueryPageResponse, } from "@nanobpm/bojtos-kit";
package/dist/index.js CHANGED
@@ -3,12 +3,12 @@
3
3
  // `<BpmnRuntimeView>` renders the live token/incident diagram. The engine's
4
4
  // snapshot/event contract types are re-exported from @nanobpm/bojtos-kit for
5
5
  // convenience.
6
- export { useBojtos, } from "./useBojtos.js";
6
+ export { useBojtos, useReadModel, } from "./useBojtos.js";
7
7
  export { Bojtos } from "./Bojtos.js";
8
8
  export { OrderFulfillmentDemo, ORDER_FULFILLMENT_BPMN, orderFulfillmentWorkers, } from "./examples/orderFulfillment.js";
9
9
  export { BpmnRuntimeView, } from "./BpmnRuntimeView.js";
10
10
  // The shared activity log (#9). Trace-only imports tree-shake bpmn-js out —
11
11
  // TraceTimeline imports only the kit + React, never BpmnRuntimeView.
12
12
  export { TraceTimeline } from "./TraceTimeline.js";
13
- export { describeRunState, markerKey, bpmnKey, resourceList, capEvents, } from "./runState.js";
13
+ export { describeRunState, markerKey, bpmnKey, resourceList, capEvents, resolveVariant, selectReadModel, } from "./runState.js";
14
14
  export { JobFailure, settleReason, unhandledJobTypes, buildTraceItems, isTraceTurnGroup, foldEngineEvents, traceEntriesToRows, } from "@nanobpm/bojtos-kit";
@@ -7,6 +7,24 @@
7
7
  * a run reads aloud, which resources to deploy and how far to trim the log are
8
8
  * decisions, and decisions are worth testing.
9
9
  */
10
+ import type { EngineVariant } from "@nanobpm/bojtos-kit";
11
+ /**
12
+ * The engine variant a hook run should use, defaulting to `"lean"` when the
13
+ * consumer didn't pick one. Keeping the default here (rather than a parameter
14
+ * default) makes the "no `variant` means lean, so existing consumers are
15
+ * unaffected" decision a single testable fact instead of an inline `?? "lean"`
16
+ * scattered through the hook.
17
+ */
18
+ export declare function resolveVariant(variant: EngineVariant | undefined): EngineVariant;
19
+ /**
20
+ * Pull a value out of the read-model channel, or `null` when there is no live
21
+ * read-model session (loading, a lean-variant hook, or between a teardown and
22
+ * the next engine). This is the one place the "no session → null, otherwise run
23
+ * the query" decision lives, shared by every reactive read method so a
24
+ * lean-variant call or a mid-load call is a quiet `null` rather than a throw on
25
+ * a missing engine.
26
+ */
27
+ export declare function selectReadModel<C, T>(channel: C | null, select: (channel: C) => T): T | null;
10
28
  /**
11
29
  * Stable key for a marker set, so unchanged ids don't re-paint the diagram.
12
30
  *
package/dist/runState.js CHANGED
@@ -7,6 +7,27 @@
7
7
  * a run reads aloud, which resources to deploy and how far to trim the log are
8
8
  * decisions, and decisions are worth testing.
9
9
  */
10
+ /**
11
+ * The engine variant a hook run should use, defaulting to `"lean"` when the
12
+ * consumer didn't pick one. Keeping the default here (rather than a parameter
13
+ * default) makes the "no `variant` means lean, so existing consumers are
14
+ * unaffected" decision a single testable fact instead of an inline `?? "lean"`
15
+ * scattered through the hook.
16
+ */
17
+ export function resolveVariant(variant) {
18
+ return variant ?? "lean";
19
+ }
20
+ /**
21
+ * Pull a value out of the read-model channel, or `null` when there is no live
22
+ * read-model session (loading, a lean-variant hook, or between a teardown and
23
+ * the next engine). This is the one place the "no session → null, otherwise run
24
+ * the query" decision lives, shared by every reactive read method so a
25
+ * lean-variant call or a mid-load call is a quiet `null` rather than a throw on
26
+ * a missing engine.
27
+ */
28
+ export function selectReadModel(channel, select) {
29
+ return channel === null ? null : select(channel);
30
+ }
10
31
  /**
11
32
  * Stable key for a marker set, so unchanged ids don't re-paint the diagram.
12
33
  *
@@ -1,4 +1,4 @@
1
- import { type ActivateInstruction, type AgentResult, type DispatchOptions, type JobHandler, type RoundResult, type Snapshot, type WasmEvent, type WasmSource } from "@nanobpm/bojtos-kit";
1
+ import { type ActivateInstruction, type AgentResult, type DispatchOptions, type EngineReadModel, type EngineVariant, type FormResult, type JobHandler, type ProcessInstanceSearchQueryResult, type ResourceResult, type RoundResult, type Snapshot, type UserTaskSearchQueryResult, type VariableSearchQueryResult, type WasmEvent, type WasmSource } from "@nanobpm/bojtos-kit";
2
2
  /** Lifecycle of the in-browser engine load. */
3
3
  export type BojtosPhase = "loading" | "ready" | "error";
4
4
  export interface UseBojtosOptions {
@@ -29,6 +29,22 @@ export interface UseBojtosOptions {
29
29
  * whole log, which stays the default so existing consumers are unaffected.
30
30
  */
31
31
  maxEvents?: number;
32
+ /**
33
+ * Which engine variant to load, defaulting to `"lean"` — existing consumers
34
+ * are unaffected. Pass `"readmodel"` to also thread the gateway's
35
+ * Camunda-parity REST read channel through the hook: the returned controls
36
+ * then widen to {@link ReadModelBojtosControls}, exposing `searchUserTasks` /
37
+ * `searchProcessInstances` / `searchVariables` / `getFormByKey` /
38
+ * `getResourceByKey` plus the `readModelVersion` reactivity signal.
39
+ *
40
+ * Init-time only, like `wasm`: the variant is read when the session is first
41
+ * created for a given diagram, so changing it later has no effect until the
42
+ * next `bpmn` change re-creates the engine.
43
+ *
44
+ * The read-model binary is heavier and only code-splits in when this is
45
+ * `"readmodel"` (ADR 0043 §3); a lean hook never downloads it.
46
+ */
47
+ variant?: EngineVariant;
32
48
  }
33
49
  export interface BojtosControls {
34
50
  phase: BojtosPhase;
@@ -132,6 +148,78 @@ export interface BojtosControls {
132
148
  /** Re-deploy the diagram on the existing engine, clearing run state. */
133
149
  reset(): void;
134
150
  }
151
+ /**
152
+ * The {@link BojtosControls} of a `readmodel`-variant hook: the full command
153
+ * surface **plus** reactive access to the gateway's Camunda-parity REST read
154
+ * channel. You get one by passing `variant: "readmodel"` to {@link useBojtos},
155
+ * which widens the return type from `BojtosControls` to this.
156
+ *
157
+ * ## Reactivity model
158
+ *
159
+ * The read queries are **pull** projections of the read model, not part of the
160
+ * command→`snapshot` push loop: `searchUserTasks` et al. answer "what does the
161
+ * read model say *right now*", and there is no single obvious cadence at which
162
+ * to re-run them (a consumer may care about tasks, another about variables, each
163
+ * with its own filter). So rather than eagerly re-running every query after
164
+ * every command and stuffing five results into state, the hook exposes:
165
+ *
166
+ * - the five read methods as **imperative pulls** — call one whenever you want a
167
+ * fresh answer; each returns `null` before the engine is ready rather than
168
+ * throwing, and
169
+ * - {@link readModelVersion}, a counter bumped after **every** command / worker
170
+ * round (i.e. whenever the read model may have moved), so a consumer can make
171
+ * a query reactive by keying a `useMemo`/`useEffect` on it — or just let
172
+ * {@link useReadModel} do exactly that.
173
+ *
174
+ * This keeps the read channel opt-in and filter-agnostic while still landing its
175
+ * results in React state on the consumer's terms.
176
+ */
177
+ export interface ReadModelBojtosControls extends BojtosControls {
178
+ /**
179
+ * Search user tasks through the read model (mirrors `POST
180
+ * /user-tasks/search`). Returns `null` until the engine is ready. Honours an
181
+ * optional `{ state? }` filter, e.g. `searchUserTasks('{"state":"CREATED"}')`.
182
+ */
183
+ searchUserTasks(filterJson?: string): UserTaskSearchQueryResult | null;
184
+ /**
185
+ * Search process instances through the read model (mirrors `POST
186
+ * /process-instances/search`). Returns `null` until the engine is ready.
187
+ */
188
+ searchProcessInstances(filterJson?: string): ProcessInstanceSearchQueryResult | null;
189
+ /**
190
+ * Search variables through the read model (mirrors `POST
191
+ * /variables/search`). Returns `null` until the engine is ready.
192
+ */
193
+ searchVariables(filterJson?: string): VariableSearchQueryResult | null;
194
+ /**
195
+ * The latest deployed form for `formKey` (mirrors `GET /forms/{formKey}`), or
196
+ * `null` if none exists — also `null` until the engine is ready.
197
+ */
198
+ getFormByKey(formKey: string): FormResult | null;
199
+ /**
200
+ * The generic resource for `resourceKey` (mirrors `GET
201
+ * /resources/{resourceKey}`), or `null` if none exists — also `null` until the
202
+ * engine is ready.
203
+ */
204
+ getResourceByKey(resourceKey: string): ResourceResult | null;
205
+ /**
206
+ * This session's engine read-model handle as `@nanobpm/engine-testkit`'s
207
+ * structural `EngineReadModel` port — feed it straight to `assertThatInstance`
208
+ * / `assertThatUserTask`. Returns `null` until the engine is ready (or on a
209
+ * lean-variant hook). Like the other read queries it is a pull projection: the
210
+ * handle reads live engine state on each call, so re-read it (keyed on
211
+ * {@link readModelVersion}) rather than caching its results.
212
+ */
213
+ readModel(): EngineReadModel | null;
214
+ /**
215
+ * A monotonically increasing counter bumped after every command / worker round
216
+ * (and on deploy / reset). It is the reactivity signal for the pull read
217
+ * queries: key a `useMemo`/`useEffect` on it to re-run a query when the read
218
+ * model may have changed. {@link useReadModel} is the ready-made selector over
219
+ * it.
220
+ */
221
+ readModelVersion: number;
222
+ }
135
223
  /**
136
224
  * React binding over a headless {@link BojtosSession}: owns the engine's
137
225
  * lifecycle and the reactive `snapshot` / `events` / `processIds` state, and
@@ -142,5 +230,38 @@ export interface BojtosControls {
142
230
  * This is the reactive half of the Bojtos public API (ADR 0043 §2); the console
143
231
  * test-run panel is its first consumer (§8 step 2 — dogfooding is the acceptance
144
232
  * test).
233
+ *
234
+ * With `variant: "readmodel"` the return type widens to
235
+ * {@link ReadModelBojtosControls}, adding the read channel + `readModelVersion`;
236
+ * the default `"lean"` variant returns the plain {@link BojtosControls} and never
237
+ * downloads the heavier read-model binary.
238
+ */
239
+ export declare function useBojtos(options: UseBojtosOptions & {
240
+ variant: "readmodel";
241
+ }): ReadModelBojtosControls;
242
+ export declare function useBojtos(options: UseBojtosOptions & {
243
+ variant?: "lean";
244
+ }): BojtosControls;
245
+ export declare function useBojtos(options: UseBojtosOptions): BojtosControls | ReadModelBojtosControls;
246
+ /**
247
+ * Reactively project a value out of a `readmodel` hook's read channel, re-run
248
+ * whenever the read model may have moved.
249
+ *
250
+ * The read queries are pull projections (see {@link ReadModelBojtosControls}),
251
+ * so this is the ready-made "selector" that lands their result in React state on
252
+ * your terms: pass the `readmodel` {@link useBojtos} controls and a `select`
253
+ * that calls whichever read methods you care about (with whatever filters), and
254
+ * the memoized result re-computes each time `readModelVersion` bumps — i.e.
255
+ * after every command / worker round / deploy / reset — or the load `phase`
256
+ * flips. Before the engine is ready the read methods return `null`, so a
257
+ * selector must tolerate nulls.
258
+ *
259
+ * ```tsx
260
+ * const run = useBojtos({ bpmn, variant: "readmodel" });
261
+ * const openTasks = useReadModel(
262
+ * run,
263
+ * (rm) => rm.searchUserTasks('{"state":"CREATED"}')?.items ?? [],
264
+ * );
265
+ * ```
145
266
  */
146
- export declare function useBojtos({ bpmn, wasm, maxEvents, }: UseBojtosOptions): BojtosControls;
267
+ export declare function useReadModel<T>(controls: ReadModelBojtosControls, select: (controls: ReadModelBojtosControls) => T): T;
package/dist/useBojtos.js CHANGED
@@ -1,24 +1,27 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { createBojtosSession, dispatchRound, dispatchWorkers, } from "@nanobpm/bojtos-kit";
3
- import { bpmnKey, capEvents, resourceList } from "./runState.js";
4
- /**
5
- * React binding over a headless {@link BojtosSession}: owns the engine's
6
- * lifecycle and the reactive `snapshot` / `events` / `processIds` state, and
7
- * exposes the engine commands. The consuming component owns its own form state
8
- * (selected process, seed vars, per-job output) and drives the visual contract
9
- * (`<BpmnRuntimeView>` + the variable payload) off `snapshot`.
10
- *
11
- * This is the reactive half of the Bojtos public API (ADR 0043 §2); the console
12
- * test-run panel is its first consumer (§8 step 2 — dogfooding is the acceptance
13
- * test).
14
- */
15
- export function useBojtos({ bpmn, wasm, maxEvents, }) {
3
+ import { bpmnKey, capEvents, resolveVariant, resourceList, selectReadModel, } from "./runState.js";
4
+ export function useBojtos({ bpmn, wasm, maxEvents, variant, }) {
16
5
  const sessionRef = useRef(null);
6
+ // The same session, narrowed to its read channel, but only when we actually
7
+ // asked for the `readmodel` variant. Kept as its own ref (rather than casting
8
+ // `sessionRef`) so the read methods reach the query surface without a cast — a
9
+ // lean session simply leaves this null and every read pull returns null.
10
+ const readModelRef = useRef(null);
17
11
  const [phase, setPhase] = useState("loading");
18
12
  const [error, setError] = useState(null);
19
13
  const [processIds, setProcessIds] = useState([]);
20
14
  const [snapshot, setSnapshot] = useState(null);
21
15
  const [events, setEvents] = useState([]);
16
+ // Bumped whenever the read model may have moved (any command / round / deploy /
17
+ // reset) so pull read queries can be made reactive by keying on it.
18
+ const [readModelVersion, setReadModelVersion] = useState(0);
19
+ const bumpReadModel = useCallback(() => setReadModelVersion((v) => v + 1), []);
20
+ // The variant is an init-time concern like `wasm` (read when a session is
21
+ // first created for a diagram), so keep it in a ref rather than the deploy
22
+ // effect's deps.
23
+ const variantRef = useRef(variant);
24
+ variantRef.current = variant;
22
25
  // The wasm source is an init-time concern (the first `ensureWasm` wins), so
23
26
  // keep it in a ref rather than the mount effect's deps — a fresh URL/bytes
24
27
  // identity each render must not re-create the session.
@@ -47,6 +50,9 @@ export function useBojtos({ bpmn, wasm, maxEvents, }) {
47
50
  setSnapshot(null);
48
51
  setEvents([]);
49
52
  setError(null);
53
+ // The read model was just wiped and re-seeded by the redeploy, so any
54
+ // reactive read query must re-run.
55
+ bumpReadModel();
50
56
  },
51
57
  // eslint-disable-next-line react-hooks/exhaustive-deps
52
58
  [deployKey]);
@@ -61,7 +67,14 @@ export function useBojtos({ bpmn, wasm, maxEvents, }) {
61
67
  setSnapshot(null);
62
68
  setEvents([]);
63
69
  setError(null);
64
- createBojtosSession({ wasm: wasmRef.current })
70
+ // Resolve the session with the requested variant. The `readmodel` branch
71
+ // keeps the narrowed `ReadModelBojtosSession` so the read methods reach the
72
+ // query surface without a cast; the lean branch leaves `readModelRef` null.
73
+ const variant = resolveVariant(variantRef.current);
74
+ const pending = variant === "readmodel"
75
+ ? createBojtosSession({ wasm: wasmRef.current, variant: "readmodel" })
76
+ : createBojtosSession({ wasm: wasmRef.current });
77
+ pending
65
78
  .then((session) => {
66
79
  if (cancelled) {
67
80
  session.free();
@@ -80,6 +93,8 @@ export function useBojtos({ bpmn, wasm, maxEvents, }) {
80
93
  return;
81
94
  }
82
95
  sessionRef.current = session;
96
+ readModelRef.current =
97
+ variant === "readmodel" ? session : null;
83
98
  setPhase("ready");
84
99
  })
85
100
  .catch((e) => {
@@ -92,6 +107,7 @@ export function useBojtos({ bpmn, wasm, maxEvents, }) {
92
107
  cancelled = true;
93
108
  sessionRef.current?.free();
94
109
  sessionRef.current = null;
110
+ readModelRef.current = null;
95
111
  };
96
112
  }, [deployInto]);
97
113
  const run = useCallback((fn) => {
@@ -103,13 +119,15 @@ export function useBojtos({ bpmn, wasm, maxEvents, }) {
103
119
  setSnapshot(snap);
104
120
  setEvents(readEvents(session));
105
121
  setError(null);
122
+ // A command may have moved the read model; signal reactive readers.
123
+ bumpReadModel();
106
124
  return snap;
107
125
  }
108
126
  catch (e) {
109
127
  setError(String(e));
110
128
  return null;
111
129
  }
112
- }, []);
130
+ }, [bumpReadModel, readEvents]);
113
131
  const createInstance = useCallback((processId, variablesJson) => run((s) => s.createInstance(processId, variablesJson)), [run]);
114
132
  const completeJob = useCallback((jobKey, variablesJson) => run((s) => s.completeJob(jobKey, variablesJson)), [run]);
115
133
  const completeAgentJob = useCallback((jobKey, result) => run((s) => s.completeAgentJob(jobKey, result)), [run]);
@@ -140,6 +158,7 @@ export function useBojtos({ bpmn, wasm, maxEvents, }) {
140
158
  setSnapshot(settled);
141
159
  setEvents(readEvents(session));
142
160
  setError(null);
161
+ bumpReadModel();
143
162
  return settled;
144
163
  }
145
164
  catch (e) {
@@ -150,9 +169,10 @@ export function useBojtos({ bpmn, wasm, maxEvents, }) {
150
169
  setSnapshot(session.snapshot());
151
170
  setEvents(readEvents(session));
152
171
  setError(String(e));
172
+ bumpReadModel();
153
173
  return null;
154
174
  }
155
- }, []);
175
+ }, [bumpReadModel, readEvents]);
156
176
  const stepWorkers = useCallback(async (workers, opts) => {
157
177
  const session = sessionRef.current;
158
178
  if (!session)
@@ -165,6 +185,7 @@ export function useBojtos({ bpmn, wasm, maxEvents, }) {
165
185
  setSnapshot(round.snapshot);
166
186
  setEvents(readEvents(session));
167
187
  setError(null);
188
+ bumpReadModel();
168
189
  return round;
169
190
  }
170
191
  catch (e) {
@@ -173,9 +194,10 @@ export function useBojtos({ bpmn, wasm, maxEvents, }) {
173
194
  setSnapshot(session.snapshot());
174
195
  setEvents(readEvents(session));
175
196
  setError(String(e));
197
+ bumpReadModel();
176
198
  return null;
177
199
  }
178
- }, []);
200
+ }, [bumpReadModel, readEvents]);
179
201
  const reset = useCallback(() => {
180
202
  const session = sessionRef.current;
181
203
  if (!session)
@@ -191,6 +213,17 @@ export function useBojtos({ bpmn, wasm, maxEvents, }) {
191
213
  setError(String(e));
192
214
  }
193
215
  }, [deployInto]);
216
+ // The read channel. Each pull returns null when there is no live read-model
217
+ // session (loading, or a lean-variant hook), via the shared `selectReadModel`
218
+ // guard, rather than throwing on a missing engine. They intentionally do not
219
+ // touch React state themselves — reactivity is opt-in through
220
+ // `readModelVersion` / `useReadModel` (see `ReadModelBojtosControls`).
221
+ const searchUserTasks = useCallback((filterJson) => selectReadModel(readModelRef.current, (rm) => rm.searchUserTasks(filterJson)), []);
222
+ const searchProcessInstances = useCallback((filterJson) => selectReadModel(readModelRef.current, (rm) => rm.searchProcessInstances(filterJson)), []);
223
+ const searchVariables = useCallback((filterJson) => selectReadModel(readModelRef.current, (rm) => rm.searchVariables(filterJson)), []);
224
+ const getFormByKey = useCallback((formKey) => selectReadModel(readModelRef.current, (rm) => rm.getFormByKey(formKey)), []);
225
+ const getResourceByKey = useCallback((resourceKey) => selectReadModel(readModelRef.current, (rm) => rm.getResourceByKey(resourceKey)), []);
226
+ const readModel = useCallback(() => selectReadModel(readModelRef.current, (rm) => rm.readModel()), []);
194
227
  return {
195
228
  phase,
196
229
  error,
@@ -217,5 +250,45 @@ export function useBojtos({ bpmn, wasm, maxEvents, }) {
217
250
  runWorkers,
218
251
  stepWorkers,
219
252
  reset,
253
+ searchUserTasks,
254
+ searchProcessInstances,
255
+ searchVariables,
256
+ getFormByKey,
257
+ getResourceByKey,
258
+ readModel,
259
+ readModelVersion,
220
260
  };
221
261
  }
262
+ /**
263
+ * Reactively project a value out of a `readmodel` hook's read channel, re-run
264
+ * whenever the read model may have moved.
265
+ *
266
+ * The read queries are pull projections (see {@link ReadModelBojtosControls}),
267
+ * so this is the ready-made "selector" that lands their result in React state on
268
+ * your terms: pass the `readmodel` {@link useBojtos} controls and a `select`
269
+ * that calls whichever read methods you care about (with whatever filters), and
270
+ * the memoized result re-computes each time `readModelVersion` bumps — i.e.
271
+ * after every command / worker round / deploy / reset — or the load `phase`
272
+ * flips. Before the engine is ready the read methods return `null`, so a
273
+ * selector must tolerate nulls.
274
+ *
275
+ * ```tsx
276
+ * const run = useBojtos({ bpmn, variant: "readmodel" });
277
+ * const openTasks = useReadModel(
278
+ * run,
279
+ * (rm) => rm.searchUserTasks('{"state":"CREATED"}')?.items ?? [],
280
+ * );
281
+ * ```
282
+ */
283
+ export function useReadModel(controls, select) {
284
+ // Keep the latest selector and controls without making them memo dependencies:
285
+ // re-running is driven by the read model moving (`readModelVersion`) / readiness
286
+ // (`phase`), not by a fresh inline selector or a fresh `controls` object literal
287
+ // (`useBojtos` returns a new object each render, so depending on it directly would
288
+ // re-run the selector on *every* parent re-render).
289
+ const selectRef = useRef(select);
290
+ selectRef.current = select;
291
+ const controlsRef = useRef(controls);
292
+ controlsRef.current = controls;
293
+ return useMemo(() => selectRef.current(controlsRef.current), [controls.readModelVersion, controls.phase]);
294
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/bojtos-react",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "React binding for the Bojtos in-browser BPMN demo framework (ADR 0043): the useBojtos hook (owns the engine session + reactive snapshot/event state) and the <BpmnRuntimeView> live token/incident diagram. Built on @nanobpm/bojtos-kit; the console test-run panel is its first consumer.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -24,6 +24,9 @@
24
24
  "src"
25
25
  ],
26
26
  "sideEffects": false,
27
+ "engines": {
28
+ "node": ">=22.6"
29
+ },
27
30
  "scripts": {
28
31
  "build": "tsc -p tsconfig.json",
29
32
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -32,7 +35,7 @@
32
35
  "test:ci": "node --experimental-strip-types --test test/*.test.ts"
33
36
  },
34
37
  "dependencies": {
35
- "@nanobpm/bojtos-kit": "^0.7.0"
38
+ "@nanobpm/bojtos-kit": "^0.9.0"
36
39
  },
37
40
  "peerDependencies": {
38
41
  "bpmn-js": ">=17",
package/src/index.ts CHANGED
@@ -6,8 +6,10 @@
6
6
 
7
7
  export {
8
8
  useBojtos,
9
+ useReadModel,
9
10
  type UseBojtosOptions,
10
11
  type BojtosControls,
12
+ type ReadModelBojtosControls,
11
13
  type BojtosPhase,
12
14
  } from "./useBojtos.js";
13
15
  export { Bojtos, type BojtosProps, type TraceEvent } from "./Bojtos.js";
@@ -29,6 +31,8 @@ export {
29
31
  bpmnKey,
30
32
  resourceList,
31
33
  capEvents,
34
+ resolveVariant,
35
+ selectReadModel,
32
36
  } from "./runState.js";
33
37
  export {
34
38
  JobFailure,
@@ -48,6 +52,8 @@ export {
48
52
  } from "@nanobpm/bojtos-kit";
49
53
  export type {
50
54
  BojtosSession,
55
+ ReadModelBojtosSession,
56
+ EngineVariant,
51
57
  Snapshot,
52
58
  InstanceDto,
53
59
  JobDto,
@@ -71,4 +77,14 @@ export type {
71
77
  TraceTurnGroup,
72
78
  TraceItem,
73
79
  TraceAdapter,
80
+ UserTaskSearchQueryResult,
81
+ UserTaskResult,
82
+ ProcessInstanceSearchQueryResult,
83
+ ProcessInstanceResult,
84
+ VariableSearchQueryResult,
85
+ VariableResult,
86
+ FormResult,
87
+ ResourceResult,
88
+ SearchQueryResponse,
89
+ SearchQueryPageResponse,
74
90
  } from "@nanobpm/bojtos-kit";
package/src/runState.ts CHANGED
@@ -8,6 +8,36 @@
8
8
  * decisions, and decisions are worth testing.
9
9
  */
10
10
 
11
+ import type { EngineVariant } from "@nanobpm/bojtos-kit";
12
+
13
+ /**
14
+ * The engine variant a hook run should use, defaulting to `"lean"` when the
15
+ * consumer didn't pick one. Keeping the default here (rather than a parameter
16
+ * default) makes the "no `variant` means lean, so existing consumers are
17
+ * unaffected" decision a single testable fact instead of an inline `?? "lean"`
18
+ * scattered through the hook.
19
+ */
20
+ export function resolveVariant(
21
+ variant: EngineVariant | undefined,
22
+ ): EngineVariant {
23
+ return variant ?? "lean";
24
+ }
25
+
26
+ /**
27
+ * Pull a value out of the read-model channel, or `null` when there is no live
28
+ * read-model session (loading, a lean-variant hook, or between a teardown and
29
+ * the next engine). This is the one place the "no session → null, otherwise run
30
+ * the query" decision lives, shared by every reactive read method so a
31
+ * lean-variant call or a mid-load call is a quiet `null` rather than a throw on
32
+ * a missing engine.
33
+ */
34
+ export function selectReadModel<C, T>(
35
+ channel: C | null,
36
+ select: (channel: C) => T,
37
+ ): T | null {
38
+ return channel === null ? null : select(channel);
39
+ }
40
+
11
41
  /**
12
42
  * Stable key for a marker set, so unchanged ids don't re-paint the diagram.
13
43
  *
package/src/useBojtos.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import {
3
3
  type ActivateInstruction,
4
4
  type AgentResult,
@@ -7,13 +7,27 @@ import {
7
7
  type DispatchOptions,
8
8
  dispatchRound,
9
9
  dispatchWorkers,
10
+ type EngineReadModel,
11
+ type EngineVariant,
12
+ type FormResult,
10
13
  type JobHandler,
14
+ type ProcessInstanceSearchQueryResult,
15
+ type ReadModelBojtosSession,
16
+ type ResourceResult,
11
17
  type RoundResult,
12
18
  type Snapshot,
19
+ type UserTaskSearchQueryResult,
20
+ type VariableSearchQueryResult,
13
21
  type WasmEvent,
14
22
  type WasmSource,
15
23
  } from "@nanobpm/bojtos-kit";
16
- import { bpmnKey, capEvents, resourceList } from "./runState.js";
24
+ import {
25
+ bpmnKey,
26
+ capEvents,
27
+ resolveVariant,
28
+ resourceList,
29
+ selectReadModel,
30
+ } from "./runState.js";
17
31
 
18
32
  /** Lifecycle of the in-browser engine load. */
19
33
  export type BojtosPhase = "loading" | "ready" | "error";
@@ -46,6 +60,22 @@ export interface UseBojtosOptions {
46
60
  * whole log, which stays the default so existing consumers are unaffected.
47
61
  */
48
62
  maxEvents?: number;
63
+ /**
64
+ * Which engine variant to load, defaulting to `"lean"` — existing consumers
65
+ * are unaffected. Pass `"readmodel"` to also thread the gateway's
66
+ * Camunda-parity REST read channel through the hook: the returned controls
67
+ * then widen to {@link ReadModelBojtosControls}, exposing `searchUserTasks` /
68
+ * `searchProcessInstances` / `searchVariables` / `getFormByKey` /
69
+ * `getResourceByKey` plus the `readModelVersion` reactivity signal.
70
+ *
71
+ * Init-time only, like `wasm`: the variant is read when the session is first
72
+ * created for a given diagram, so changing it later has no effect until the
73
+ * next `bpmn` change re-creates the engine.
74
+ *
75
+ * The read-model binary is heavier and only code-splits in when this is
76
+ * `"readmodel"` (ADR 0043 §3); a lean hook never downloads it.
77
+ */
78
+ variant?: EngineVariant;
49
79
  }
50
80
 
51
81
  export interface BojtosControls {
@@ -177,6 +207,81 @@ export interface BojtosControls {
177
207
  reset(): void;
178
208
  }
179
209
 
210
+ /**
211
+ * The {@link BojtosControls} of a `readmodel`-variant hook: the full command
212
+ * surface **plus** reactive access to the gateway's Camunda-parity REST read
213
+ * channel. You get one by passing `variant: "readmodel"` to {@link useBojtos},
214
+ * which widens the return type from `BojtosControls` to this.
215
+ *
216
+ * ## Reactivity model
217
+ *
218
+ * The read queries are **pull** projections of the read model, not part of the
219
+ * command→`snapshot` push loop: `searchUserTasks` et al. answer "what does the
220
+ * read model say *right now*", and there is no single obvious cadence at which
221
+ * to re-run them (a consumer may care about tasks, another about variables, each
222
+ * with its own filter). So rather than eagerly re-running every query after
223
+ * every command and stuffing five results into state, the hook exposes:
224
+ *
225
+ * - the five read methods as **imperative pulls** — call one whenever you want a
226
+ * fresh answer; each returns `null` before the engine is ready rather than
227
+ * throwing, and
228
+ * - {@link readModelVersion}, a counter bumped after **every** command / worker
229
+ * round (i.e. whenever the read model may have moved), so a consumer can make
230
+ * a query reactive by keying a `useMemo`/`useEffect` on it — or just let
231
+ * {@link useReadModel} do exactly that.
232
+ *
233
+ * This keeps the read channel opt-in and filter-agnostic while still landing its
234
+ * results in React state on the consumer's terms.
235
+ */
236
+ export interface ReadModelBojtosControls extends BojtosControls {
237
+ /**
238
+ * Search user tasks through the read model (mirrors `POST
239
+ * /user-tasks/search`). Returns `null` until the engine is ready. Honours an
240
+ * optional `{ state? }` filter, e.g. `searchUserTasks('{"state":"CREATED"}')`.
241
+ */
242
+ searchUserTasks(filterJson?: string): UserTaskSearchQueryResult | null;
243
+ /**
244
+ * Search process instances through the read model (mirrors `POST
245
+ * /process-instances/search`). Returns `null` until the engine is ready.
246
+ */
247
+ searchProcessInstances(
248
+ filterJson?: string,
249
+ ): ProcessInstanceSearchQueryResult | null;
250
+ /**
251
+ * Search variables through the read model (mirrors `POST
252
+ * /variables/search`). Returns `null` until the engine is ready.
253
+ */
254
+ searchVariables(filterJson?: string): VariableSearchQueryResult | null;
255
+ /**
256
+ * The latest deployed form for `formKey` (mirrors `GET /forms/{formKey}`), or
257
+ * `null` if none exists — also `null` until the engine is ready.
258
+ */
259
+ getFormByKey(formKey: string): FormResult | null;
260
+ /**
261
+ * The generic resource for `resourceKey` (mirrors `GET
262
+ * /resources/{resourceKey}`), or `null` if none exists — also `null` until the
263
+ * engine is ready.
264
+ */
265
+ getResourceByKey(resourceKey: string): ResourceResult | null;
266
+ /**
267
+ * This session's engine read-model handle as `@nanobpm/engine-testkit`'s
268
+ * structural `EngineReadModel` port — feed it straight to `assertThatInstance`
269
+ * / `assertThatUserTask`. Returns `null` until the engine is ready (or on a
270
+ * lean-variant hook). Like the other read queries it is a pull projection: the
271
+ * handle reads live engine state on each call, so re-read it (keyed on
272
+ * {@link readModelVersion}) rather than caching its results.
273
+ */
274
+ readModel(): EngineReadModel | null;
275
+ /**
276
+ * A monotonically increasing counter bumped after every command / worker round
277
+ * (and on deploy / reset). It is the reactivity signal for the pull read
278
+ * queries: key a `useMemo`/`useEffect` on it to re-run a query when the read
279
+ * model may have changed. {@link useReadModel} is the ready-made selector over
280
+ * it.
281
+ */
282
+ readModelVersion: number;
283
+ }
284
+
180
285
  /**
181
286
  * Session members the hook deliberately does not re-export: the deployment
182
287
  * lifecycle it owns itself, and the low-level activate primitive the dispatch
@@ -200,6 +305,20 @@ type UnboundCommands = Exclude<
200
305
  type AssertNever<T extends never> = T;
201
306
  type _EverySessionCommandIsBound = AssertNever<UnboundCommands>;
202
307
 
308
+ /**
309
+ * The same guard for the widened `readmodel` surface: every method of a
310
+ * {@link ReadModelBojtosSession} — the lean commands **and** the five read
311
+ * queries — must be bound on {@link ReadModelBojtosControls}, or a `readmodel`
312
+ * hook would silently drop part of the read channel (the exact failure mode #1
313
+ * described, now covering the read methods too). Adding a read query to the
314
+ * session without binding it here fails the build with its name.
315
+ */
316
+ type UnboundReadModelCommands = Exclude<
317
+ keyof ReadModelBojtosSession,
318
+ NotReExported | keyof ReadModelBojtosControls
319
+ >;
320
+ type _EveryReadModelCommandIsBound = AssertNever<UnboundReadModelCommands>;
321
+
203
322
  /**
204
323
  * React binding over a headless {@link BojtosSession}: owns the engine's
205
324
  * lifecycle and the reactive `snapshot` / `events` / `processIds` state, and
@@ -210,18 +329,51 @@ type _EverySessionCommandIsBound = AssertNever<UnboundCommands>;
210
329
  * This is the reactive half of the Bojtos public API (ADR 0043 §2); the console
211
330
  * test-run panel is its first consumer (§8 step 2 — dogfooding is the acceptance
212
331
  * test).
332
+ *
333
+ * With `variant: "readmodel"` the return type widens to
334
+ * {@link ReadModelBojtosControls}, adding the read channel + `readModelVersion`;
335
+ * the default `"lean"` variant returns the plain {@link BojtosControls} and never
336
+ * downloads the heavier read-model binary.
213
337
  */
338
+ export function useBojtos(
339
+ options: UseBojtosOptions & { variant: "readmodel" },
340
+ ): ReadModelBojtosControls;
341
+ export function useBojtos(
342
+ options: UseBojtosOptions & { variant?: "lean" },
343
+ ): BojtosControls;
344
+ export function useBojtos(
345
+ options: UseBojtosOptions,
346
+ ): BojtosControls | ReadModelBojtosControls;
214
347
  export function useBojtos({
215
348
  bpmn,
216
349
  wasm,
217
350
  maxEvents,
218
- }: UseBojtosOptions): BojtosControls {
351
+ variant,
352
+ }: UseBojtosOptions): ReadModelBojtosControls {
219
353
  const sessionRef = useRef<BojtosSession | null>(null);
354
+ // The same session, narrowed to its read channel, but only when we actually
355
+ // asked for the `readmodel` variant. Kept as its own ref (rather than casting
356
+ // `sessionRef`) so the read methods reach the query surface without a cast — a
357
+ // lean session simply leaves this null and every read pull returns null.
358
+ const readModelRef = useRef<ReadModelBojtosSession | null>(null);
220
359
  const [phase, setPhase] = useState<BojtosPhase>("loading");
221
360
  const [error, setError] = useState<string | null>(null);
222
361
  const [processIds, setProcessIds] = useState<string[]>([]);
223
362
  const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
224
363
  const [events, setEvents] = useState<WasmEvent[]>([]);
364
+ // Bumped whenever the read model may have moved (any command / round / deploy /
365
+ // reset) so pull read queries can be made reactive by keying on it.
366
+ const [readModelVersion, setReadModelVersion] = useState(0);
367
+ const bumpReadModel = useCallback(
368
+ () => setReadModelVersion((v) => v + 1),
369
+ [],
370
+ );
371
+
372
+ // The variant is an init-time concern like `wasm` (read when a session is
373
+ // first created for a diagram), so keep it in a ref rather than the deploy
374
+ // effect's deps.
375
+ const variantRef = useRef(variant);
376
+ variantRef.current = variant;
225
377
 
226
378
  // The wasm source is an init-time concern (the first `ensureWasm` wins), so
227
379
  // keep it in a ref rather than the mount effect's deps — a fresh URL/bytes
@@ -254,6 +406,9 @@ export function useBojtos({
254
406
  setSnapshot(null);
255
407
  setEvents([]);
256
408
  setError(null);
409
+ // The read model was just wiped and re-seeded by the redeploy, so any
410
+ // reactive read query must re-run.
411
+ bumpReadModel();
257
412
  },
258
413
  // eslint-disable-next-line react-hooks/exhaustive-deps
259
414
  [deployKey],
@@ -270,7 +425,15 @@ export function useBojtos({
270
425
  setSnapshot(null);
271
426
  setEvents([]);
272
427
  setError(null);
273
- createBojtosSession({ wasm: wasmRef.current })
428
+ // Resolve the session with the requested variant. The `readmodel` branch
429
+ // keeps the narrowed `ReadModelBojtosSession` so the read methods reach the
430
+ // query surface without a cast; the lean branch leaves `readModelRef` null.
431
+ const variant = resolveVariant(variantRef.current);
432
+ const pending =
433
+ variant === "readmodel"
434
+ ? createBojtosSession({ wasm: wasmRef.current, variant: "readmodel" })
435
+ : createBojtosSession({ wasm: wasmRef.current });
436
+ pending
274
437
  .then((session) => {
275
438
  if (cancelled) {
276
439
  session.free();
@@ -288,6 +451,8 @@ export function useBojtos({
288
451
  return;
289
452
  }
290
453
  sessionRef.current = session;
454
+ readModelRef.current =
455
+ variant === "readmodel" ? (session as ReadModelBojtosSession) : null;
291
456
  setPhase("ready");
292
457
  })
293
458
  .catch((e) => {
@@ -299,6 +464,7 @@ export function useBojtos({
299
464
  cancelled = true;
300
465
  sessionRef.current?.free();
301
466
  sessionRef.current = null;
467
+ readModelRef.current = null;
302
468
  };
303
469
  }, [deployInto]);
304
470
 
@@ -311,13 +477,15 @@ export function useBojtos({
311
477
  setSnapshot(snap);
312
478
  setEvents(readEvents(session));
313
479
  setError(null);
480
+ // A command may have moved the read model; signal reactive readers.
481
+ bumpReadModel();
314
482
  return snap;
315
483
  } catch (e) {
316
484
  setError(String(e));
317
485
  return null;
318
486
  }
319
487
  },
320
- [],
488
+ [bumpReadModel, readEvents],
321
489
  );
322
490
 
323
491
  const createInstance = useCallback(
@@ -431,6 +599,7 @@ export function useBojtos({
431
599
  setSnapshot(settled);
432
600
  setEvents(readEvents(session));
433
601
  setError(null);
602
+ bumpReadModel();
434
603
  return settled;
435
604
  } catch (e) {
436
605
  if (sessionRef.current !== session) return null;
@@ -439,10 +608,11 @@ export function useBojtos({
439
608
  setSnapshot(session.snapshot());
440
609
  setEvents(readEvents(session));
441
610
  setError(String(e));
611
+ bumpReadModel();
442
612
  return null;
443
613
  }
444
614
  },
445
- [],
615
+ [bumpReadModel, readEvents],
446
616
  );
447
617
 
448
618
  const stepWorkers = useCallback(
@@ -459,16 +629,18 @@ export function useBojtos({
459
629
  setSnapshot(round.snapshot);
460
630
  setEvents(readEvents(session));
461
631
  setError(null);
632
+ bumpReadModel();
462
633
  return round;
463
634
  } catch (e) {
464
635
  if (sessionRef.current !== session) return null;
465
636
  setSnapshot(session.snapshot());
466
637
  setEvents(readEvents(session));
467
638
  setError(String(e));
639
+ bumpReadModel();
468
640
  return null;
469
641
  }
470
642
  },
471
- [],
643
+ [bumpReadModel, readEvents],
472
644
  );
473
645
 
474
646
  const reset = useCallback(() => {
@@ -485,6 +657,49 @@ export function useBojtos({
485
657
  }
486
658
  }, [deployInto]);
487
659
 
660
+ // The read channel. Each pull returns null when there is no live read-model
661
+ // session (loading, or a lean-variant hook), via the shared `selectReadModel`
662
+ // guard, rather than throwing on a missing engine. They intentionally do not
663
+ // touch React state themselves — reactivity is opt-in through
664
+ // `readModelVersion` / `useReadModel` (see `ReadModelBojtosControls`).
665
+ const searchUserTasks = useCallback(
666
+ (filterJson?: string) =>
667
+ selectReadModel(readModelRef.current, (rm) =>
668
+ rm.searchUserTasks(filterJson),
669
+ ),
670
+ [],
671
+ );
672
+ const searchProcessInstances = useCallback(
673
+ (filterJson?: string) =>
674
+ selectReadModel(readModelRef.current, (rm) =>
675
+ rm.searchProcessInstances(filterJson),
676
+ ),
677
+ [],
678
+ );
679
+ const searchVariables = useCallback(
680
+ (filterJson?: string) =>
681
+ selectReadModel(readModelRef.current, (rm) =>
682
+ rm.searchVariables(filterJson),
683
+ ),
684
+ [],
685
+ );
686
+ const getFormByKey = useCallback(
687
+ (formKey: string) =>
688
+ selectReadModel(readModelRef.current, (rm) => rm.getFormByKey(formKey)),
689
+ [],
690
+ );
691
+ const getResourceByKey = useCallback(
692
+ (resourceKey: string) =>
693
+ selectReadModel(readModelRef.current, (rm) =>
694
+ rm.getResourceByKey(resourceKey),
695
+ ),
696
+ [],
697
+ );
698
+ const readModel = useCallback(
699
+ () => selectReadModel(readModelRef.current, (rm) => rm.readModel()),
700
+ [],
701
+ );
702
+
488
703
  return {
489
704
  phase,
490
705
  error,
@@ -511,5 +726,52 @@ export function useBojtos({
511
726
  runWorkers,
512
727
  stepWorkers,
513
728
  reset,
729
+ searchUserTasks,
730
+ searchProcessInstances,
731
+ searchVariables,
732
+ getFormByKey,
733
+ getResourceByKey,
734
+ readModel,
735
+ readModelVersion,
514
736
  };
515
737
  }
738
+
739
+ /**
740
+ * Reactively project a value out of a `readmodel` hook's read channel, re-run
741
+ * whenever the read model may have moved.
742
+ *
743
+ * The read queries are pull projections (see {@link ReadModelBojtosControls}),
744
+ * so this is the ready-made "selector" that lands their result in React state on
745
+ * your terms: pass the `readmodel` {@link useBojtos} controls and a `select`
746
+ * that calls whichever read methods you care about (with whatever filters), and
747
+ * the memoized result re-computes each time `readModelVersion` bumps — i.e.
748
+ * after every command / worker round / deploy / reset — or the load `phase`
749
+ * flips. Before the engine is ready the read methods return `null`, so a
750
+ * selector must tolerate nulls.
751
+ *
752
+ * ```tsx
753
+ * const run = useBojtos({ bpmn, variant: "readmodel" });
754
+ * const openTasks = useReadModel(
755
+ * run,
756
+ * (rm) => rm.searchUserTasks('{"state":"CREATED"}')?.items ?? [],
757
+ * );
758
+ * ```
759
+ */
760
+ export function useReadModel<T>(
761
+ controls: ReadModelBojtosControls,
762
+ select: (controls: ReadModelBojtosControls) => T,
763
+ ): T {
764
+ // Keep the latest selector and controls without making them memo dependencies:
765
+ // re-running is driven by the read model moving (`readModelVersion`) / readiness
766
+ // (`phase`), not by a fresh inline selector or a fresh `controls` object literal
767
+ // (`useBojtos` returns a new object each render, so depending on it directly would
768
+ // re-run the selector on *every* parent re-render).
769
+ const selectRef = useRef(select);
770
+ selectRef.current = select;
771
+ const controlsRef = useRef(controls);
772
+ controlsRef.current = controls;
773
+ return useMemo(
774
+ () => selectRef.current(controlsRef.current),
775
+ [controls.readModelVersion, controls.phase],
776
+ );
777
+ }