@nanobpm/bojtos-react 0.6.0 → 0.8.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 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,69 @@ 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
+ * A monotonically increasing counter bumped after every command / worker round
207
+ * (and on deploy / reset). It is the reactivity signal for the pull read
208
+ * queries: key a `useMemo`/`useEffect` on it to re-run a query when the read
209
+ * model may have changed. {@link useReadModel} is the ready-made selector over
210
+ * it.
211
+ */
212
+ readModelVersion: number;
213
+ }
135
214
  /**
136
215
  * React binding over a headless {@link BojtosSession}: owns the engine's
137
216
  * lifecycle and the reactive `snapshot` / `events` / `processIds` state, and
@@ -142,5 +221,38 @@ export interface BojtosControls {
142
221
  * This is the reactive half of the Bojtos public API (ADR 0043 §2); the console
143
222
  * test-run panel is its first consumer (§8 step 2 — dogfooding is the acceptance
144
223
  * test).
224
+ *
225
+ * With `variant: "readmodel"` the return type widens to
226
+ * {@link ReadModelBojtosControls}, adding the read channel + `readModelVersion`;
227
+ * the default `"lean"` variant returns the plain {@link BojtosControls} and never
228
+ * downloads the heavier read-model binary.
229
+ */
230
+ export declare function useBojtos(options: UseBojtosOptions & {
231
+ variant: "readmodel";
232
+ }): ReadModelBojtosControls;
233
+ export declare function useBojtos(options: UseBojtosOptions & {
234
+ variant?: "lean";
235
+ }): BojtosControls;
236
+ export declare function useBojtos(options: UseBojtosOptions): BojtosControls | ReadModelBojtosControls;
237
+ /**
238
+ * Reactively project a value out of a `readmodel` hook's read channel, re-run
239
+ * whenever the read model may have moved.
240
+ *
241
+ * The read queries are pull projections (see {@link ReadModelBojtosControls}),
242
+ * so this is the ready-made "selector" that lands their result in React state on
243
+ * your terms: pass the `readmodel` {@link useBojtos} controls and a `select`
244
+ * that calls whichever read methods you care about (with whatever filters), and
245
+ * the memoized result re-computes each time `readModelVersion` bumps — i.e.
246
+ * after every command / worker round / deploy / reset — or the load `phase`
247
+ * flips. Before the engine is ready the read methods return `null`, so a
248
+ * selector must tolerate nulls.
249
+ *
250
+ * ```tsx
251
+ * const run = useBojtos({ bpmn, variant: "readmodel" });
252
+ * const openTasks = useReadModel(
253
+ * run,
254
+ * (rm) => rm.searchUserTasks('{"state":"CREATED"}')?.items ?? [],
255
+ * );
256
+ * ```
145
257
  */
146
- export declare function useBojtos({ bpmn, wasm, maxEvents, }: UseBojtosOptions): BojtosControls;
258
+ 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,16 @@ 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)), []);
194
226
  return {
195
227
  phase,
196
228
  error,
@@ -217,5 +249,44 @@ export function useBojtos({ bpmn, wasm, maxEvents, }) {
217
249
  runWorkers,
218
250
  stepWorkers,
219
251
  reset,
252
+ searchUserTasks,
253
+ searchProcessInstances,
254
+ searchVariables,
255
+ getFormByKey,
256
+ getResourceByKey,
257
+ readModelVersion,
220
258
  };
221
259
  }
260
+ /**
261
+ * Reactively project a value out of a `readmodel` hook's read channel, re-run
262
+ * whenever the read model may have moved.
263
+ *
264
+ * The read queries are pull projections (see {@link ReadModelBojtosControls}),
265
+ * so this is the ready-made "selector" that lands their result in React state on
266
+ * your terms: pass the `readmodel` {@link useBojtos} controls and a `select`
267
+ * that calls whichever read methods you care about (with whatever filters), and
268
+ * the memoized result re-computes each time `readModelVersion` bumps — i.e.
269
+ * after every command / worker round / deploy / reset — or the load `phase`
270
+ * flips. Before the engine is ready the read methods return `null`, so a
271
+ * selector must tolerate nulls.
272
+ *
273
+ * ```tsx
274
+ * const run = useBojtos({ bpmn, variant: "readmodel" });
275
+ * const openTasks = useReadModel(
276
+ * run,
277
+ * (rm) => rm.searchUserTasks('{"state":"CREATED"}')?.items ?? [],
278
+ * );
279
+ * ```
280
+ */
281
+ export function useReadModel(controls, select) {
282
+ // Keep the latest selector and controls without making them memo dependencies:
283
+ // re-running is driven by the read model moving (`readModelVersion`) / readiness
284
+ // (`phase`), not by a fresh inline selector or a fresh `controls` object literal
285
+ // (`useBojtos` returns a new object each render, so depending on it directly would
286
+ // re-run the selector on *every* parent re-render).
287
+ const selectRef = useRef(select);
288
+ selectRef.current = select;
289
+ const controlsRef = useRef(controls);
290
+ controlsRef.current = controls;
291
+ return useMemo(() => selectRef.current(controlsRef.current), [controls.readModelVersion, controls.phase]);
292
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/bojtos-react",
3
- "version": "0.6.0",
3
+ "version": "0.8.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",
@@ -32,7 +32,7 @@
32
32
  "test:ci": "node --experimental-strip-types --test test/*.test.ts"
33
33
  },
34
34
  "dependencies": {
35
- "@nanobpm/bojtos-kit": "^0.6.0"
35
+ "@nanobpm/bojtos-kit": "^0.8.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "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,26 @@ import {
7
7
  type DispatchOptions,
8
8
  dispatchRound,
9
9
  dispatchWorkers,
10
+ type EngineVariant,
11
+ type FormResult,
10
12
  type JobHandler,
13
+ type ProcessInstanceSearchQueryResult,
14
+ type ReadModelBojtosSession,
15
+ type ResourceResult,
11
16
  type RoundResult,
12
17
  type Snapshot,
18
+ type UserTaskSearchQueryResult,
19
+ type VariableSearchQueryResult,
13
20
  type WasmEvent,
14
21
  type WasmSource,
15
22
  } from "@nanobpm/bojtos-kit";
16
- import { bpmnKey, capEvents, resourceList } from "./runState.js";
23
+ import {
24
+ bpmnKey,
25
+ capEvents,
26
+ resolveVariant,
27
+ resourceList,
28
+ selectReadModel,
29
+ } from "./runState.js";
17
30
 
18
31
  /** Lifecycle of the in-browser engine load. */
19
32
  export type BojtosPhase = "loading" | "ready" | "error";
@@ -46,6 +59,22 @@ export interface UseBojtosOptions {
46
59
  * whole log, which stays the default so existing consumers are unaffected.
47
60
  */
48
61
  maxEvents?: number;
62
+ /**
63
+ * Which engine variant to load, defaulting to `"lean"` — existing consumers
64
+ * are unaffected. Pass `"readmodel"` to also thread the gateway's
65
+ * Camunda-parity REST read channel through the hook: the returned controls
66
+ * then widen to {@link ReadModelBojtosControls}, exposing `searchUserTasks` /
67
+ * `searchProcessInstances` / `searchVariables` / `getFormByKey` /
68
+ * `getResourceByKey` plus the `readModelVersion` reactivity signal.
69
+ *
70
+ * Init-time only, like `wasm`: the variant is read when the session is first
71
+ * created for a given diagram, so changing it later has no effect until the
72
+ * next `bpmn` change re-creates the engine.
73
+ *
74
+ * The read-model binary is heavier and only code-splits in when this is
75
+ * `"readmodel"` (ADR 0043 §3); a lean hook never downloads it.
76
+ */
77
+ variant?: EngineVariant;
49
78
  }
50
79
 
51
80
  export interface BojtosControls {
@@ -177,6 +206,72 @@ export interface BojtosControls {
177
206
  reset(): void;
178
207
  }
179
208
 
209
+ /**
210
+ * The {@link BojtosControls} of a `readmodel`-variant hook: the full command
211
+ * surface **plus** reactive access to the gateway's Camunda-parity REST read
212
+ * channel. You get one by passing `variant: "readmodel"` to {@link useBojtos},
213
+ * which widens the return type from `BojtosControls` to this.
214
+ *
215
+ * ## Reactivity model
216
+ *
217
+ * The read queries are **pull** projections of the read model, not part of the
218
+ * command→`snapshot` push loop: `searchUserTasks` et al. answer "what does the
219
+ * read model say *right now*", and there is no single obvious cadence at which
220
+ * to re-run them (a consumer may care about tasks, another about variables, each
221
+ * with its own filter). So rather than eagerly re-running every query after
222
+ * every command and stuffing five results into state, the hook exposes:
223
+ *
224
+ * - the five read methods as **imperative pulls** — call one whenever you want a
225
+ * fresh answer; each returns `null` before the engine is ready rather than
226
+ * throwing, and
227
+ * - {@link readModelVersion}, a counter bumped after **every** command / worker
228
+ * round (i.e. whenever the read model may have moved), so a consumer can make
229
+ * a query reactive by keying a `useMemo`/`useEffect` on it — or just let
230
+ * {@link useReadModel} do exactly that.
231
+ *
232
+ * This keeps the read channel opt-in and filter-agnostic while still landing its
233
+ * results in React state on the consumer's terms.
234
+ */
235
+ export interface ReadModelBojtosControls extends BojtosControls {
236
+ /**
237
+ * Search user tasks through the read model (mirrors `POST
238
+ * /user-tasks/search`). Returns `null` until the engine is ready. Honours an
239
+ * optional `{ state? }` filter, e.g. `searchUserTasks('{"state":"CREATED"}')`.
240
+ */
241
+ searchUserTasks(filterJson?: string): UserTaskSearchQueryResult | null;
242
+ /**
243
+ * Search process instances through the read model (mirrors `POST
244
+ * /process-instances/search`). Returns `null` until the engine is ready.
245
+ */
246
+ searchProcessInstances(
247
+ filterJson?: string,
248
+ ): ProcessInstanceSearchQueryResult | null;
249
+ /**
250
+ * Search variables through the read model (mirrors `POST
251
+ * /variables/search`). Returns `null` until the engine is ready.
252
+ */
253
+ searchVariables(filterJson?: string): VariableSearchQueryResult | null;
254
+ /**
255
+ * The latest deployed form for `formKey` (mirrors `GET /forms/{formKey}`), or
256
+ * `null` if none exists — also `null` until the engine is ready.
257
+ */
258
+ getFormByKey(formKey: string): FormResult | null;
259
+ /**
260
+ * The generic resource for `resourceKey` (mirrors `GET
261
+ * /resources/{resourceKey}`), or `null` if none exists — also `null` until the
262
+ * engine is ready.
263
+ */
264
+ getResourceByKey(resourceKey: string): ResourceResult | null;
265
+ /**
266
+ * A monotonically increasing counter bumped after every command / worker round
267
+ * (and on deploy / reset). It is the reactivity signal for the pull read
268
+ * queries: key a `useMemo`/`useEffect` on it to re-run a query when the read
269
+ * model may have changed. {@link useReadModel} is the ready-made selector over
270
+ * it.
271
+ */
272
+ readModelVersion: number;
273
+ }
274
+
180
275
  /**
181
276
  * Session members the hook deliberately does not re-export: the deployment
182
277
  * lifecycle it owns itself, and the low-level activate primitive the dispatch
@@ -200,6 +295,20 @@ type UnboundCommands = Exclude<
200
295
  type AssertNever<T extends never> = T;
201
296
  type _EverySessionCommandIsBound = AssertNever<UnboundCommands>;
202
297
 
298
+ /**
299
+ * The same guard for the widened `readmodel` surface: every method of a
300
+ * {@link ReadModelBojtosSession} — the lean commands **and** the five read
301
+ * queries — must be bound on {@link ReadModelBojtosControls}, or a `readmodel`
302
+ * hook would silently drop part of the read channel (the exact failure mode #1
303
+ * described, now covering the read methods too). Adding a read query to the
304
+ * session without binding it here fails the build with its name.
305
+ */
306
+ type UnboundReadModelCommands = Exclude<
307
+ keyof ReadModelBojtosSession,
308
+ NotReExported | keyof ReadModelBojtosControls
309
+ >;
310
+ type _EveryReadModelCommandIsBound = AssertNever<UnboundReadModelCommands>;
311
+
203
312
  /**
204
313
  * React binding over a headless {@link BojtosSession}: owns the engine's
205
314
  * lifecycle and the reactive `snapshot` / `events` / `processIds` state, and
@@ -210,18 +319,51 @@ type _EverySessionCommandIsBound = AssertNever<UnboundCommands>;
210
319
  * This is the reactive half of the Bojtos public API (ADR 0043 §2); the console
211
320
  * test-run panel is its first consumer (§8 step 2 — dogfooding is the acceptance
212
321
  * test).
322
+ *
323
+ * With `variant: "readmodel"` the return type widens to
324
+ * {@link ReadModelBojtosControls}, adding the read channel + `readModelVersion`;
325
+ * the default `"lean"` variant returns the plain {@link BojtosControls} and never
326
+ * downloads the heavier read-model binary.
213
327
  */
328
+ export function useBojtos(
329
+ options: UseBojtosOptions & { variant: "readmodel" },
330
+ ): ReadModelBojtosControls;
331
+ export function useBojtos(
332
+ options: UseBojtosOptions & { variant?: "lean" },
333
+ ): BojtosControls;
334
+ export function useBojtos(
335
+ options: UseBojtosOptions,
336
+ ): BojtosControls | ReadModelBojtosControls;
214
337
  export function useBojtos({
215
338
  bpmn,
216
339
  wasm,
217
340
  maxEvents,
218
- }: UseBojtosOptions): BojtosControls {
341
+ variant,
342
+ }: UseBojtosOptions): ReadModelBojtosControls {
219
343
  const sessionRef = useRef<BojtosSession | null>(null);
344
+ // The same session, narrowed to its read channel, but only when we actually
345
+ // asked for the `readmodel` variant. Kept as its own ref (rather than casting
346
+ // `sessionRef`) so the read methods reach the query surface without a cast — a
347
+ // lean session simply leaves this null and every read pull returns null.
348
+ const readModelRef = useRef<ReadModelBojtosSession | null>(null);
220
349
  const [phase, setPhase] = useState<BojtosPhase>("loading");
221
350
  const [error, setError] = useState<string | null>(null);
222
351
  const [processIds, setProcessIds] = useState<string[]>([]);
223
352
  const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
224
353
  const [events, setEvents] = useState<WasmEvent[]>([]);
354
+ // Bumped whenever the read model may have moved (any command / round / deploy /
355
+ // reset) so pull read queries can be made reactive by keying on it.
356
+ const [readModelVersion, setReadModelVersion] = useState(0);
357
+ const bumpReadModel = useCallback(
358
+ () => setReadModelVersion((v) => v + 1),
359
+ [],
360
+ );
361
+
362
+ // The variant is an init-time concern like `wasm` (read when a session is
363
+ // first created for a diagram), so keep it in a ref rather than the deploy
364
+ // effect's deps.
365
+ const variantRef = useRef(variant);
366
+ variantRef.current = variant;
225
367
 
226
368
  // The wasm source is an init-time concern (the first `ensureWasm` wins), so
227
369
  // keep it in a ref rather than the mount effect's deps — a fresh URL/bytes
@@ -254,6 +396,9 @@ export function useBojtos({
254
396
  setSnapshot(null);
255
397
  setEvents([]);
256
398
  setError(null);
399
+ // The read model was just wiped and re-seeded by the redeploy, so any
400
+ // reactive read query must re-run.
401
+ bumpReadModel();
257
402
  },
258
403
  // eslint-disable-next-line react-hooks/exhaustive-deps
259
404
  [deployKey],
@@ -270,7 +415,15 @@ export function useBojtos({
270
415
  setSnapshot(null);
271
416
  setEvents([]);
272
417
  setError(null);
273
- createBojtosSession({ wasm: wasmRef.current })
418
+ // Resolve the session with the requested variant. The `readmodel` branch
419
+ // keeps the narrowed `ReadModelBojtosSession` so the read methods reach the
420
+ // query surface without a cast; the lean branch leaves `readModelRef` null.
421
+ const variant = resolveVariant(variantRef.current);
422
+ const pending =
423
+ variant === "readmodel"
424
+ ? createBojtosSession({ wasm: wasmRef.current, variant: "readmodel" })
425
+ : createBojtosSession({ wasm: wasmRef.current });
426
+ pending
274
427
  .then((session) => {
275
428
  if (cancelled) {
276
429
  session.free();
@@ -288,6 +441,8 @@ export function useBojtos({
288
441
  return;
289
442
  }
290
443
  sessionRef.current = session;
444
+ readModelRef.current =
445
+ variant === "readmodel" ? (session as ReadModelBojtosSession) : null;
291
446
  setPhase("ready");
292
447
  })
293
448
  .catch((e) => {
@@ -299,6 +454,7 @@ export function useBojtos({
299
454
  cancelled = true;
300
455
  sessionRef.current?.free();
301
456
  sessionRef.current = null;
457
+ readModelRef.current = null;
302
458
  };
303
459
  }, [deployInto]);
304
460
 
@@ -311,13 +467,15 @@ export function useBojtos({
311
467
  setSnapshot(snap);
312
468
  setEvents(readEvents(session));
313
469
  setError(null);
470
+ // A command may have moved the read model; signal reactive readers.
471
+ bumpReadModel();
314
472
  return snap;
315
473
  } catch (e) {
316
474
  setError(String(e));
317
475
  return null;
318
476
  }
319
477
  },
320
- [],
478
+ [bumpReadModel, readEvents],
321
479
  );
322
480
 
323
481
  const createInstance = useCallback(
@@ -431,6 +589,7 @@ export function useBojtos({
431
589
  setSnapshot(settled);
432
590
  setEvents(readEvents(session));
433
591
  setError(null);
592
+ bumpReadModel();
434
593
  return settled;
435
594
  } catch (e) {
436
595
  if (sessionRef.current !== session) return null;
@@ -439,10 +598,11 @@ export function useBojtos({
439
598
  setSnapshot(session.snapshot());
440
599
  setEvents(readEvents(session));
441
600
  setError(String(e));
601
+ bumpReadModel();
442
602
  return null;
443
603
  }
444
604
  },
445
- [],
605
+ [bumpReadModel, readEvents],
446
606
  );
447
607
 
448
608
  const stepWorkers = useCallback(
@@ -459,16 +619,18 @@ export function useBojtos({
459
619
  setSnapshot(round.snapshot);
460
620
  setEvents(readEvents(session));
461
621
  setError(null);
622
+ bumpReadModel();
462
623
  return round;
463
624
  } catch (e) {
464
625
  if (sessionRef.current !== session) return null;
465
626
  setSnapshot(session.snapshot());
466
627
  setEvents(readEvents(session));
467
628
  setError(String(e));
629
+ bumpReadModel();
468
630
  return null;
469
631
  }
470
632
  },
471
- [],
633
+ [bumpReadModel, readEvents],
472
634
  );
473
635
 
474
636
  const reset = useCallback(() => {
@@ -485,6 +647,45 @@ export function useBojtos({
485
647
  }
486
648
  }, [deployInto]);
487
649
 
650
+ // The read channel. Each pull returns null when there is no live read-model
651
+ // session (loading, or a lean-variant hook), via the shared `selectReadModel`
652
+ // guard, rather than throwing on a missing engine. They intentionally do not
653
+ // touch React state themselves — reactivity is opt-in through
654
+ // `readModelVersion` / `useReadModel` (see `ReadModelBojtosControls`).
655
+ const searchUserTasks = useCallback(
656
+ (filterJson?: string) =>
657
+ selectReadModel(readModelRef.current, (rm) =>
658
+ rm.searchUserTasks(filterJson),
659
+ ),
660
+ [],
661
+ );
662
+ const searchProcessInstances = useCallback(
663
+ (filterJson?: string) =>
664
+ selectReadModel(readModelRef.current, (rm) =>
665
+ rm.searchProcessInstances(filterJson),
666
+ ),
667
+ [],
668
+ );
669
+ const searchVariables = useCallback(
670
+ (filterJson?: string) =>
671
+ selectReadModel(readModelRef.current, (rm) =>
672
+ rm.searchVariables(filterJson),
673
+ ),
674
+ [],
675
+ );
676
+ const getFormByKey = useCallback(
677
+ (formKey: string) =>
678
+ selectReadModel(readModelRef.current, (rm) => rm.getFormByKey(formKey)),
679
+ [],
680
+ );
681
+ const getResourceByKey = useCallback(
682
+ (resourceKey: string) =>
683
+ selectReadModel(readModelRef.current, (rm) =>
684
+ rm.getResourceByKey(resourceKey),
685
+ ),
686
+ [],
687
+ );
688
+
488
689
  return {
489
690
  phase,
490
691
  error,
@@ -511,5 +712,51 @@ export function useBojtos({
511
712
  runWorkers,
512
713
  stepWorkers,
513
714
  reset,
715
+ searchUserTasks,
716
+ searchProcessInstances,
717
+ searchVariables,
718
+ getFormByKey,
719
+ getResourceByKey,
720
+ readModelVersion,
514
721
  };
515
722
  }
723
+
724
+ /**
725
+ * Reactively project a value out of a `readmodel` hook's read channel, re-run
726
+ * whenever the read model may have moved.
727
+ *
728
+ * The read queries are pull projections (see {@link ReadModelBojtosControls}),
729
+ * so this is the ready-made "selector" that lands their result in React state on
730
+ * your terms: pass the `readmodel` {@link useBojtos} controls and a `select`
731
+ * that calls whichever read methods you care about (with whatever filters), and
732
+ * the memoized result re-computes each time `readModelVersion` bumps — i.e.
733
+ * after every command / worker round / deploy / reset — or the load `phase`
734
+ * flips. Before the engine is ready the read methods return `null`, so a
735
+ * selector must tolerate nulls.
736
+ *
737
+ * ```tsx
738
+ * const run = useBojtos({ bpmn, variant: "readmodel" });
739
+ * const openTasks = useReadModel(
740
+ * run,
741
+ * (rm) => rm.searchUserTasks('{"state":"CREATED"}')?.items ?? [],
742
+ * );
743
+ * ```
744
+ */
745
+ export function useReadModel<T>(
746
+ controls: ReadModelBojtosControls,
747
+ select: (controls: ReadModelBojtosControls) => T,
748
+ ): T {
749
+ // Keep the latest selector and controls without making them memo dependencies:
750
+ // re-running is driven by the read model moving (`readModelVersion`) / readiness
751
+ // (`phase`), not by a fresh inline selector or a fresh `controls` object literal
752
+ // (`useBojtos` returns a new object each render, so depending on it directly would
753
+ // re-run the selector on *every* parent re-render).
754
+ const selectRef = useRef(select);
755
+ selectRef.current = select;
756
+ const controlsRef = useRef(controls);
757
+ controlsRef.current = controls;
758
+ return useMemo(
759
+ () => selectRef.current(controlsRef.current),
760
+ [controls.readModelVersion, controls.phase],
761
+ );
762
+ }