@shenora/react 0.9.1 → 0.11.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.
@@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from 'react';
2
2
  import { getBridge } from './bridge.js';
3
3
  import { eventBus as defaultEventBus } from './eventBus.js';
4
4
  import { debounce, randomId } from './internal.js';
5
- /** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`DropZoneFacade`). */
6
- export const DROP_ZONE_MODULE = 'DROP_ZONE';
5
+ /** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`DropZoneModule`). */
6
+ export const DROP_ZONE_MODULE = 'SHENORA.DROPZONE';
7
7
  const newZoneId = () => randomId('drop-zone-');
8
8
  /**
9
9
  * **The file-drop API for a Shenora page. Do not use the DOM's own drop event for files —
@@ -30,13 +30,34 @@ const newZoneId = () => randomId('drop-zone-');
30
30
  */
31
31
  export function useDropZone(options) {
32
32
  const { targetRef, enabled = true } = options;
33
- const zoneIdRef = useRef(options.zoneId ?? newZoneId());
33
+ // ⚠ LAZY, because `useRef(newZoneId())` evaluates its argument on EVERY render and keeps only the
34
+ // first — so the generator ran a `crypto.randomUUID()` per render of every drop zone, for a value
35
+ // used once. The empty string is a safe sentinel: a generated id is never empty, and a caller who
36
+ // passes `zoneId: ''` short-circuits the `??` and so still never reaches the generator.
37
+ const zoneIdRef = useRef('');
38
+ if (zoneIdRef.current === '')
39
+ zoneIdRef.current = options.zoneId ?? newZoneId();
40
+ // ⚠ Read ONCE, like the id, and unlike `onDrop`/`bridge` below which track the latest value. That is
41
+ // deliberate rather than an oversight: the hover effect captures this class for its cleanup, while
42
+ // the FILE_DROP effect reads it live, so a value that could change mid-hover would let one path add
43
+ // class A and another remove class B — leaving A stuck on the element with no drag in progress. The
44
+ // default is a constant, so unlike the id there is nothing here worth making lazy.
34
45
  const dropClassRef = useRef(options.dropClassName ?? 'shenora-drop-hover');
35
46
  const onDropRef = useRef(options.onDrop);
36
47
  onDropRef.current = options.onDrop;
37
48
  const bridgeRef = useRef(options.bridge);
38
49
  bridgeRef.current = options.bridge;
39
50
  const bus = options.bus ?? defaultEventBus;
51
+ // Tracks the latest handler (like `onDrop`), so a cleanup that runs long after mount still reports
52
+ // through the sink the app has NOW. Only when the app supplied none does this log — a caller that
53
+ // took `onError` owns its reporting and must not be double-logged, the rule the package's other
54
+ // three sinks follow.
55
+ const onErrorRef = useRef(options.onError);
56
+ onErrorRef.current = options.onError;
57
+ const reportRef = useRef(() => { });
58
+ reportRef.current = (error, route) => onErrorRef.current
59
+ ? onErrorRef.current(error, route)
60
+ : console.error(`[shenora] drop-zone ${route} failed:`, error);
40
61
  // Make the ref's CONTENT reactive (P5.5 H2). `targetRef` is a stable object, so effects keyed on it
41
62
  // run exactly once — and if `targetRef.current` was null on that run (a conditionally-rendered
42
63
  // target, or any order where the ref is attached after the first commit) the effect bailed out and
@@ -91,7 +112,7 @@ export function useDropZone(options) {
91
112
  .then(() => {
92
113
  if (epochRef.current === epoch)
93
114
  isRegisteredRef.current = true;
94
- }, (error) => console.error('[shenora] drop-zone REGISTER failed:', error))
115
+ }, (error) => reportRef.current(error, 'REGISTER'))
95
116
  .finally(() => {
96
117
  if (epochRef.current === epoch)
97
118
  registeringRef.current = false;
@@ -101,7 +122,7 @@ export function useDropZone(options) {
101
122
  lastBoundsRef.current = bounds;
102
123
  bridge
103
124
  .invoke(DROP_ZONE_MODULE, 'UPDATE', { payload: { zoneId: zoneIdRef.current, ...bounds } })
104
- .catch((error) => console.error('[shenora] drop-zone UPDATE failed:', error));
125
+ .catch((error) => reportRef.current(error, 'UPDATE'));
105
126
  }
106
127
  };
107
128
  // Track the element and keep the native overlay in sync.
@@ -115,7 +136,7 @@ export function useDropZone(options) {
115
136
  const sendShow = debounce(() => {
116
137
  (bridgeRef.current ?? getBridge())
117
138
  .invoke(DROP_ZONE_MODULE, 'SHOW', { payload: { zoneId: zoneIdRef.current } })
118
- .catch((error) => console.error('[shenora] drop-zone SHOW failed:', error));
139
+ .catch((error) => reportRef.current(error, 'SHOW'));
119
140
  }, 100);
120
141
  // The element's mouseleave (and the window losing focus) re-arm the overlay — native
121
142
  // MouseLeave alone is unreliable through the WebView.
@@ -152,7 +173,7 @@ export function useDropZone(options) {
152
173
  if (attemptedRef.current) {
153
174
  (bridgeRef.current ?? getBridge())
154
175
  .invoke(DROP_ZONE_MODULE, 'UNREGISTER', { payload: { zoneId: zoneIdRef.current } })
155
- .catch((error) => console.error('[shenora] drop-zone UNREGISTER failed:', error));
176
+ .catch((error) => reportRef.current(error, 'UNREGISTER'));
156
177
  epochRef.current++; // invalidate any in-flight REGISTER's ack (see epochRef)
157
178
  isRegisteredRef.current = false;
158
179
  registeringRef.current = false; // a remount must re-send immediately
@@ -33,7 +33,7 @@ interface WindowRequests {
33
33
  };
34
34
  }
35
35
  /**
36
- * Typed client for the host's `WINDOW` module (`WindowCommandFacade` in Shenora.Windows) —
36
+ * Typed client for the host's `SHENORA.WINDOW` module (`WindowCommandModule` in Shenora.Windows) —
37
37
  * drive the frameless window's chrome from the page: chrome buttons call
38
38
  * `minimize`/`toggleMaximize`/`close`, the header's `onMouseDown` calls `startDrag` (the window
39
39
  * then drags natively — snap and multi-monitor included), and a thin strip at the very top
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
2
2
  import { debounce } from './internal.js';
3
3
  import { BaseModuleService } from './moduleService.js';
4
4
  /**
5
- * Typed client for the host's `WINDOW` module (`WindowCommandFacade` in Shenora.Windows) —
5
+ * Typed client for the host's `SHENORA.WINDOW` module (`WindowCommandModule` in Shenora.Windows) —
6
6
  * drive the frameless window's chrome from the page: chrome buttons call
7
7
  * `minimize`/`toggleMaximize`/`close`, the header's `onMouseDown` calls `startDrag` (the window
8
8
  * then drags natively — snap and multi-monitor included), and a thin strip at the very top
@@ -11,7 +11,7 @@ import { BaseModuleService } from './moduleService.js';
11
11
  */
12
12
  export class WindowCommands extends BaseModuleService {
13
13
  constructor(bridge) {
14
- super('WINDOW', bridge);
14
+ super('SHENORA.WINDOW', bridge);
15
15
  }
16
16
  minimize() {
17
17
  return this.send('MINIMIZE');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shenora/react",
3
- "version": "0.9.1",
4
- "description": "React client for Shenora desktop hosts: correlated invoke/send/subscribe over the WebView2 bridge, typed module services, hooks, and a browser fallback for pure-UI development.",
3
+ "version": "0.11.0",
4
+ "description": "React client for Shenora hosts on Windows, Android and iOS: correlated invoke/send/subscribe over the desktop postMessage bridge or the MAUI HybridWebView transport, typed module services, host-backed stores, and hooks for request tracking and media playback. Drop zones and window commands are desktop-only, because the capabilities are. Ships a browser fallback for pure-UI development.",
5
5
  "license": "MIT",
6
6
  "author": "Jiarong Gu",
7
7
  "repository": {
@@ -14,6 +14,10 @@
14
14
  "shenora",
15
15
  "webview2",
16
16
  "desktop",
17
+ "android",
18
+ "ios",
19
+ "maui",
20
+ "hybrid",
17
21
  "ipc",
18
22
  "react",
19
23
  "winforms",
@@ -44,7 +48,9 @@
44
48
  "//typecheck": "The ONLY thing that type-checks the tests — `build` excludes them and vitest transpiles without checking, so `@ts-expect-error` assertions (which pin the typed-service generic) are inert without this. Run by dev.mjs verify.",
45
49
  "typecheck": "tsc -p tsconfig.json",
46
50
  "test": "vitest run",
47
- "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
51
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
52
+ "//typecheck:floor": "The peerDependency FLOOR is a tested claim, not an assumption: type-checks the SHIPPED sources against React 18 types while everything else builds on 19, so an API that does not exist in 18 (useActionState, use, …) fails HERE instead of in a React 18 consumer's build. Run by dev.mjs verify.",
53
+ "typecheck:floor": "tsc -p tsconfig.react18.json"
48
54
  },
49
55
  "peerDependencies": {
50
56
  "react": ">=18"
@@ -52,6 +58,7 @@
52
58
  "devDependencies": {
53
59
  "@testing-library/react": "^16.3.2",
54
60
  "@types/react": "^19.2.17",
61
+ "@types/react18": "npm:@types/react@^18",
55
62
  "jsdom": "^29.1.1",
56
63
  "react": "^19.2.8",
57
64
  "react-dom": "^19.2.8",
@@ -1,256 +0,0 @@
1
- import type { ShenoraBridge } from './bridge.js';
2
- import type { ShenoraEventBus } from './eventBus.js';
3
- import { type ShenoraStore } from './store.js';
4
- import type { IpcError } from './types.js';
5
- /**
6
- * Mirrors `Shenora.Ipc.OperationStatus` (design §4.2) — crosses the wire as its camelCase name for
7
- * free: `IpcJson` already installs a camelCase `JsonStringEnumConverter`, so no per-type wiring is
8
- * needed on either side. Pinned against the host by
9
- * `WireMirrorTests.Every_operation_status_exists_on_both_sides` — a status added on one side and
10
- * not the other fails that test by name, not by a green suite that never looked.
11
- */
12
- export declare const OperationStatuses: {
13
- readonly Running: "running";
14
- readonly Completed: "completed";
15
- readonly Failed: "failed";
16
- readonly Cancelled: "cancelled";
17
- /**
18
- * Not progressing, awaiting a decision — the APP names why via {@link OperationInfo.waitReason}
19
- * (e.g. `'credentials'`/`'dns'`/`'queued'`/`'rate-limited'`), never a kit-owned reason enum. Not
20
- * one of the terminal statuses in {@link OperationsState.finished}, and never pruned as history —
21
- * a waiting entry is a pending offer, not something finished.
22
- *
23
- * Reached ONE way, and that is the point: the host's `IOperation.Wait` on a live operation. The
24
- * body is parked, not gone, so there is always something to resume. Exits via the host's
25
- * `IOperation.Resume` (back to `running`), the `DISMISS` route (to `cancelled`), or a direct
26
- * complete/fail.
27
- *
28
- * Two simplifications got it here, both undoing the same mistake — encoding HOW an entry arrived
29
- * rather than WHAT state it is in. It was once two values (`paused` and `interrupted`) that every
30
- * host transition already treated as one band, and the host once also accepted crash-checkpoint
31
- * entries with no live body at all (`RegisterWaiting` + `resumePayload`), which forced every caller
32
- * to answer "does this one have a handle?". The checkpoint half was cut in 0.2.0: crash recovery is
33
- * the APP's business — it owns the checkpoint, and a resumed run is a fresh `Start()` like any other.
34
- */
35
- readonly Waiting: "waiting";
36
- };
37
- /** One of {@link OperationStatuses}. */
38
- export type OperationStatus = (typeof OperationStatuses)[keyof typeof OperationStatuses];
39
- /**
40
- * Mirrors `Shenora.Ipc.OperationEvents` — pinned against the host by
41
- * `WireMirrorTests.Operation_event_names_match_the_host` (ALSO IN THIS BATCH, whole-branch review):
42
- * these were bare string literals with nothing comparing them to the host's own constants, so a host
43
- * rename left the suite green and the client permanently deaf to the renamed event.
44
- */
45
- export declare const OperationEventTypes: {
46
- readonly Updated: "OPERATION_UPDATED";
47
- readonly ResumeRequested: "OPERATION_RESUME_REQUESTED";
48
- /**
49
- * A client asked to wait a running operation (generic-library audit finding 3, renamed from
50
- * `PAUSE_REQUESTED`) — the owning module should call the host's `IOperation.Wait` once it has
51
- * actually stopped. Not subscribed by {@link createOperationsStore} itself, same as
52
- * {@link ResumeRequested}: it targets the owning module's own service, not the generic operations
53
- * store.
54
- */
55
- readonly WaitRequested: "OPERATION_WAIT_REQUESTED";
56
- /**
57
- * One or more operation ids left the host registry with no corresponding `Updated` snapshot —
58
- * `MaxHistory` eviction, `CLEAR_FINISHED`, and the interrupted-entry drop on `RESUME` (Finding 4,
59
- * generic-library audit). Payload is `{ operationIds: string[] }`; {@link createOperationsStore}
60
- * folds it by deleting those ids, which is what let the two hand-written optimistic prunes
61
- * (`clearFinished`/`resume` used to carry one each) be removed — one authoritative event that
62
- * cannot diverge from what the host actually did, replacing two guesses that could.
63
- */
64
- readonly Removed: "OPERATION_REMOVED";
65
- };
66
- /**
67
- * Mirrors the route names `Shenora.Ipc.OperationsFacade` switches on (its own
68
- * `ListType`/`CancelType`/`ClearFinishedType`/`ResumeType` constants) — pinned by
69
- * `WireMirrorTests.Operation_route_names_match_the_hosts_facade`, same rationale as
70
- * {@link OperationEventTypes}.
71
- */
72
- export declare const OperationRoutes: {
73
- readonly List: "LIST";
74
- readonly Cancel: "CANCEL";
75
- readonly ClearFinished: "CLEAR_FINISHED";
76
- readonly Resume: "RESUME";
77
- /** Decline a pending Waiting offer by id — mirrors {@link Cancel}'s shape. */
78
- readonly Dismiss: "DISMISS";
79
- /**
80
- * Ask the owning module to wait a running operation by id (generic-library audit finding 3,
81
- * renamed from `PAUSE`) — mirrors {@link Resume}'s shape (`{ operationId }` → `{ requested }`).
82
- * Asking is not acting: the owning module's own `IOperation.Wait` is what actually stops the work
83
- * and publishes the transition, same split as {@link Resume} vs the host's `IOperation.Resume`.
84
- */
85
- readonly Wait: "WAIT";
86
- };
87
- /**
88
- * Default `Shenora.Ipc.OperationRegistryOptions.ModuleName` — pinned by
89
- * `WireMirrorTests.The_default_operations_module_name_matches_the_host`.
90
- */
91
- export declare const OperationModuleName = "OPERATIONS";
92
- /**
93
- * Mirrors `Shenora.Ipc.OperationLabel` — human-facing text the HOST never renders itself: an
94
- * untranslated fallback plus an app i18n key and interpolation parameters (headless, D13).
95
- */
96
- export interface OperationLabel {
97
- text?: string;
98
- key?: string;
99
- parameters?: Record<string, string>;
100
- }
101
- /**
102
- * Mirrors `Shenora.Ipc.OperationProgress` — how far a tracked operation has gotten, in the APP's own
103
- * unit, never a kit-assumed percent (generic-library audit, before publish: percent is not the
104
- * mechanism, it is one way an app happens to measure). `total` is the denominator when one is known;
105
- * `undefined` means there is NO known total — an absolute count with nothing to divide by (bytes
106
- * streamed so far off a chunked response, say), never zero. `unit` is app-defined, like `kind`
107
- * (`'bytes'`, `'files'`, `'percent'`) — the kit never interprets it and ships no percent helper: render
108
- * a ratio only when `total` is set, e.g. `total ? (value / total) * 100 : undefined` — that division
109
- * is the consumer's own policy (see the README example).
110
- */
111
- export interface OperationProgress {
112
- value: number;
113
- total?: number;
114
- unit?: string;
115
- }
116
- /**
117
- * Mirrors `Shenora.Ipc.OperationInfo` — a full snapshot of one tracked operation. Every lifecycle
118
- * transition (start, progress, terminal) publishes one of these under `OPERATION_UPDATED`, so the
119
- * client folds by `id`: last write wins, with no cross-type ordering hazard.
120
- */
121
- export interface OperationInfo {
122
- id: string;
123
- module: string;
124
- kind: string;
125
- scope?: string;
126
- status: OperationStatus;
127
- progress?: OperationProgress;
128
- title?: OperationLabel;
129
- detail?: OperationLabel;
130
- /** Why the operation is `'waiting'` — an app-defined string, like `kind`; the kit never interprets it. */
131
- waitReason?: string;
132
- error?: IpcError;
133
- cancellable: boolean;
134
- startedAt: string;
135
- finishedAt?: string;
136
- }
137
- /**
138
- * State behind {@link useShenoraOperations}. Mirrors the host's THREE bands (design §5A.2):
139
- *
140
- * | Band | Getter | Exits |
141
- * |---|---|---|
142
- * | Active | {@link running} | complete / fail / cancel / wait |
143
- * | Waiting — stopped, resumable, awaiting a decision | {@link waiting} | resume / dismiss / complete / fail |
144
- * | Terminal | {@link finished} | — (prunable via `clearFinished`) |
145
- *
146
- * `waiting` is now the WHOLE band, not a union of two getters — the host's `OperationStatus` carries
147
- * only one waiting value (the former `paused`/`interrupted` pair collapsed into it, since every host
148
- * transition already treated them as one band). A waiting entry is a pending offer, not finished
149
- * history: the host never prunes it on its own, and only `Resume` or `Dismiss` removes it. Every
150
- * getter here is DERIVED from `byId` on every read — never a second copy a fold has to remember to
151
- * keep in sync. `byId` itself is the only thing any reducer here ever writes.
152
- */
153
- export interface OperationsState {
154
- byId: Record<string, OperationInfo>;
155
- /** Every currently-running operation, in `byId` order. */
156
- readonly running: OperationInfo[];
157
- /**
158
- * The WAITING band (design §5A.2): stopped, resumable, awaiting a decision — exactly what
159
- * `Dismiss`/`RequestResume` both accept, so a status bar can render "needs you" as one bucket.
160
- * Every entry here reached the band the same way (a live `Wait()` on the host), so there is no
161
- * sub-case to distinguish — see {@link OperationStatuses.Waiting}.
162
- */
163
- readonly waiting: OperationInfo[];
164
- /** Every operation that reached a terminal status (completed/failed/cancelled). */
165
- readonly finished: OperationInfo[];
166
- }
167
- /** Fire-and-forget actions exposed on {@link useShenoraOperations}, routed to `OperationsFacade`. */
168
- export interface OperationsActions {
169
- /** `CANCEL { operationId }` — the app-level cancel route `ipc-contracts` prescribes. */
170
- cancel: (operationId: string) => string;
171
- /**
172
- * `DISMISS { operationId }` — decline a pending `waiting` offer (design §5A.3), mirroring
173
- * {@link cancel}'s shape. No optimistic local prune: the host's `Dismiss` transitions the entry to
174
- * `cancelled` and publishes an ordinary `OPERATION_UPDATED` snapshot for it (same as a real
175
- * cancel), so the store already folds the result from the wire.
176
- */
177
- dismiss: (operationId: string) => string;
178
- /**
179
- * `CLEAR_FINISHED { scope? }` — drop retained finished history, forwarding this store's own
180
- * configured scope (generic-library audit finding 1) so a scoped store's "clear completed" cannot
181
- * wipe another scope's history host-side. No local mutation here: the host's
182
- * `OPERATION_REMOVED` (finding 4) is the only thing that removes a row from this store now — see
183
- * {@link OperationEventTypes.Removed}. It used to carry an optimistic local prune of every TERMINAL
184
- * entry, added because removals had no wire event at all; that guess is retired now that one exists.
185
- */
186
- clearFinished: () => string;
187
- /**
188
- * `RESUME { operationId }` — ask the host to resume a waiting operation, mirroring {@link wait}.
189
- * No local mutation: asking never changes state by itself — the owning module's own
190
- * `IOperation.Resume` publishes the `running` transition, and the store folds it from the wire like
191
- * any other `OPERATION_UPDATED`.
192
- *
193
- * This action carried an optimistic local prune through two designs and was the source of the
194
- * release's only Critical — it deleted a row the host deliberately kept, making a still-waiting
195
- * operation unreachable. The prune existed because the host's `RequestResume` was ASYMMETRIC
196
- * (removing crash-checkpoint entries, keeping live ones). The 0.2.0 design pass cut the checkpoint
197
- * half, so the asymmetry is gone at the source and there is nothing left for a client to mirror.
198
- */
199
- resume: (operationId: string) => string;
200
- /**
201
- * `WAIT { operationId }` — ask the owning module to wait a running operation (generic-library
202
- * audit finding 3, renamed from `pause`), mirroring {@link dismiss}'s shape. No optimistic local
203
- * prune: asking never changes the state by itself — the owning module's own `IOperation.Wait` is
204
- * what publishes the `waiting` transition, and the store folds it from the wire like any other
205
- * `OPERATION_UPDATED`.
206
- */
207
- wait: (operationId: string) => string;
208
- }
209
- /** Test/alternate-transport seams, a renamed host module, and an optional scope filter, for {@link createOperationsStore}. */
210
- export interface OperationsStoreOptions {
211
- /**
212
- * The request/event module this store talks to. Must match the host's
213
- * `OperationRegistryOptions.ModuleName` — default `'OPERATIONS'` on both sides — when an app
214
- * renamed it to avoid a collision with one of its own module names (the duplicate-module guard
215
- * `OperationsFacade`'s own docs describe). A store bound to the default name cannot reach a
216
- * renamed host at all, which is exactly the gap this field closes.
217
- */
218
- module?: string;
219
- /**
220
- * Optional app-defined scope, applied to THREE places so the store stays internally consistent:
221
- * the bus subscription (only deltas whose event scope matches are folded), the actions' request
222
- * envelope, and the initial `LIST` snapshot's payload (`OperationsFacade` reads its scope filter
223
- * from the payload, not the envelope — see `OperationsFacade.RouteMessageAsync`). Threading it
224
- * into only the first two would load every scope on first subscribe and never remove the
225
- * out-of-scope rows, since no delta for them ever arrives: a silent, permanent leak.
226
- */
227
- scope?: string;
228
- /** Test/multi-transport seams. Default: the shared bridge and event bus. */
229
- bridge?: ShenoraBridge;
230
- bus?: ShenoraEventBus;
231
- }
232
- /**
233
- * Build a store instance over the operations module — the factory {@link useShenoraOperations}
234
- * itself is built from. Exposed (rather than only the ready-made hook) for the same reason
235
- * `WindowCommands` takes an optional bridge: a test needs a fake bridge/bus
236
- * (`operations.test.ts`), an app that renamed the host's `OperationRegistryOptions.ModuleName`
237
- * needs a store bound to that name instead of the unreachable default, and an app running a
238
- * secondary window or auxiliary session needs its own scope-filtered instance instead of being
239
- * stuck with the shared, unscoped default.
240
- */
241
- export declare function createOperationsStore(options?: OperationsStoreOptions): ShenoraStore<OperationsState, OperationsActions>;
242
- /**
243
- * The client side of the operations primitive (design §4.6, `OperationsFacade` +
244
- * `IOperationRegistry`): snapshots via `LIST` on first subscribe, then folds `OPERATION_UPDATED` by
245
- * id — one subscription however many components read it, and a late mounter renders CURRENT state
246
- * because the host is authoritative (the store primitive's own late-mounter case is now
247
- * host-backed end to end). `running`/`waiting`/`finished` are selectors an activity panel or status
248
- * bar reads directly: `useShenoraOperations((s) => s.waiting)` for the one "needs you" bucket
249
- * (every entry in it reached the band the same way, so there is no sub-case to filter for; read
250
- * `waitReason` for WHY it is waiting). Bound to the default module/no scope — use
251
- * {@link createOperationsStore} directly for a renamed module or a scope-filtered instance.
252
- *
253
- * Headless, per D13: no component, no UI opinion, no `ProcessType`-style enum — what an operation
254
- * IS stays the app's `kind` string; this only carries the uniform lifecycle around it.
255
- */
256
- export declare const useShenoraOperations: ShenoraStore<OperationsState, OperationsActions>;
@@ -1,191 +0,0 @@
1
- import { createShenoraStore } from './store.js';
2
- /**
3
- * Mirrors `Shenora.Ipc.OperationStatus` (design §4.2) — crosses the wire as its camelCase name for
4
- * free: `IpcJson` already installs a camelCase `JsonStringEnumConverter`, so no per-type wiring is
5
- * needed on either side. Pinned against the host by
6
- * `WireMirrorTests.Every_operation_status_exists_on_both_sides` — a status added on one side and
7
- * not the other fails that test by name, not by a green suite that never looked.
8
- */
9
- export const OperationStatuses = {
10
- Running: 'running',
11
- Completed: 'completed',
12
- Failed: 'failed',
13
- Cancelled: 'cancelled',
14
- /**
15
- * Not progressing, awaiting a decision — the APP names why via {@link OperationInfo.waitReason}
16
- * (e.g. `'credentials'`/`'dns'`/`'queued'`/`'rate-limited'`), never a kit-owned reason enum. Not
17
- * one of the terminal statuses in {@link OperationsState.finished}, and never pruned as history —
18
- * a waiting entry is a pending offer, not something finished.
19
- *
20
- * Reached ONE way, and that is the point: the host's `IOperation.Wait` on a live operation. The
21
- * body is parked, not gone, so there is always something to resume. Exits via the host's
22
- * `IOperation.Resume` (back to `running`), the `DISMISS` route (to `cancelled`), or a direct
23
- * complete/fail.
24
- *
25
- * Two simplifications got it here, both undoing the same mistake — encoding HOW an entry arrived
26
- * rather than WHAT state it is in. It was once two values (`paused` and `interrupted`) that every
27
- * host transition already treated as one band, and the host once also accepted crash-checkpoint
28
- * entries with no live body at all (`RegisterWaiting` + `resumePayload`), which forced every caller
29
- * to answer "does this one have a handle?". The checkpoint half was cut in 0.2.0: crash recovery is
30
- * the APP's business — it owns the checkpoint, and a resumed run is a fresh `Start()` like any other.
31
- */
32
- Waiting: 'waiting',
33
- };
34
- /**
35
- * Mirrors `Shenora.Ipc.OperationEvents` — pinned against the host by
36
- * `WireMirrorTests.Operation_event_names_match_the_host` (ALSO IN THIS BATCH, whole-branch review):
37
- * these were bare string literals with nothing comparing them to the host's own constants, so a host
38
- * rename left the suite green and the client permanently deaf to the renamed event.
39
- */
40
- export const OperationEventTypes = {
41
- Updated: 'OPERATION_UPDATED',
42
- ResumeRequested: 'OPERATION_RESUME_REQUESTED',
43
- /**
44
- * A client asked to wait a running operation (generic-library audit finding 3, renamed from
45
- * `PAUSE_REQUESTED`) — the owning module should call the host's `IOperation.Wait` once it has
46
- * actually stopped. Not subscribed by {@link createOperationsStore} itself, same as
47
- * {@link ResumeRequested}: it targets the owning module's own service, not the generic operations
48
- * store.
49
- */
50
- WaitRequested: 'OPERATION_WAIT_REQUESTED',
51
- /**
52
- * One or more operation ids left the host registry with no corresponding `Updated` snapshot —
53
- * `MaxHistory` eviction, `CLEAR_FINISHED`, and the interrupted-entry drop on `RESUME` (Finding 4,
54
- * generic-library audit). Payload is `{ operationIds: string[] }`; {@link createOperationsStore}
55
- * folds it by deleting those ids, which is what let the two hand-written optimistic prunes
56
- * (`clearFinished`/`resume` used to carry one each) be removed — one authoritative event that
57
- * cannot diverge from what the host actually did, replacing two guesses that could.
58
- */
59
- Removed: 'OPERATION_REMOVED',
60
- };
61
- /**
62
- * Mirrors the route names `Shenora.Ipc.OperationsFacade` switches on (its own
63
- * `ListType`/`CancelType`/`ClearFinishedType`/`ResumeType` constants) — pinned by
64
- * `WireMirrorTests.Operation_route_names_match_the_hosts_facade`, same rationale as
65
- * {@link OperationEventTypes}.
66
- */
67
- export const OperationRoutes = {
68
- List: 'LIST',
69
- Cancel: 'CANCEL',
70
- ClearFinished: 'CLEAR_FINISHED',
71
- Resume: 'RESUME',
72
- /** Decline a pending Waiting offer by id — mirrors {@link Cancel}'s shape. */
73
- Dismiss: 'DISMISS',
74
- /**
75
- * Ask the owning module to wait a running operation by id (generic-library audit finding 3,
76
- * renamed from `PAUSE`) — mirrors {@link Resume}'s shape (`{ operationId }` → `{ requested }`).
77
- * Asking is not acting: the owning module's own `IOperation.Wait` is what actually stops the work
78
- * and publishes the transition, same split as {@link Resume} vs the host's `IOperation.Resume`.
79
- */
80
- Wait: 'WAIT',
81
- };
82
- /**
83
- * Default `Shenora.Ipc.OperationRegistryOptions.ModuleName` — pinned by
84
- * `WireMirrorTests.The_default_operations_module_name_matches_the_host`.
85
- */
86
- export const OperationModuleName = 'OPERATIONS';
87
- /**
88
- * Statuses that count as finished history. Deliberately excludes `waiting` — a resumable operation
89
- * (whether a live `Wait()` or a crash-announced checkpoint) is a pending offer, "distinct from
90
- * finished history" (`Shenora.Ipc.OperationStatus.Waiting`), not a terminal outcome.
91
- */
92
- const TERMINAL_STATUSES = new Set([
93
- OperationStatuses.Completed,
94
- OperationStatuses.Failed,
95
- OperationStatuses.Cancelled,
96
- ]);
97
- function index(list) {
98
- const byId = {};
99
- for (const operation of list)
100
- byId[operation.id] = operation;
101
- return byId;
102
- }
103
- /**
104
- * The one place `running`/`waiting`/`finished` are computed — wrap `byId` here, nowhere else.
105
- */
106
- function makeState(byId) {
107
- return {
108
- byId,
109
- get running() {
110
- return Object.values(byId).filter((operation) => operation.status === OperationStatuses.Running);
111
- },
112
- get waiting() {
113
- return Object.values(byId).filter((operation) => operation.status === OperationStatuses.Waiting);
114
- },
115
- get finished() {
116
- return Object.values(byId).filter((operation) => TERMINAL_STATUSES.has(operation.status));
117
- },
118
- };
119
- }
120
- /**
121
- * Build a store instance over the operations module — the factory {@link useShenoraOperations}
122
- * itself is built from. Exposed (rather than only the ready-made hook) for the same reason
123
- * `WindowCommands` takes an optional bridge: a test needs a fake bridge/bus
124
- * (`operations.test.ts`), an app that renamed the host's `OperationRegistryOptions.ModuleName`
125
- * needs a store bound to that name instead of the unreachable default, and an app running a
126
- * secondary window or auxiliary session needs its own scope-filtered instance instead of being
127
- * stuck with the shared, unscoped default.
128
- */
129
- export function createOperationsStore(options = {}) {
130
- const module = options.module ?? OperationModuleName;
131
- return createShenoraStore(module, {
132
- initial: makeState({}),
133
- // LIST is the snapshot source (design §4.6): a store cannot replay a stream, so a component
134
- // that mounts while work is already running gets it from here before folding any deltas.
135
- // The payload carries `scope` so the initial load is filtered the SAME way the deltas are
136
- // (below, and via createShenoraStore's own `scope` option) — both halves must agree, or a
137
- // scoped store loads every scope once and then never sheds the out-of-scope rows.
138
- snapshot: {
139
- type: OperationRoutes.List,
140
- payload: options.scope !== undefined ? { scope: options.scope } : undefined,
141
- apply: (_state, data) => makeState(index(data)),
142
- },
143
- on: {
144
- // ONE event type for every transition (design §4.3) — last-write-wins by id, so folding needs
145
- // no ordering logic and no cross-type races.
146
- [OperationEventTypes.Updated]: (state, payload) => makeState({ ...state.byId, [payload.id]: payload }),
147
- // The ONE removal delta (Finding 4, generic-library audit), replacing the two hand-written
148
- // optimistic prunes `clearFinished`/`resume` used to carry (see their own docs below) — deletes
149
- // exactly the ids the host named, regardless of status; an id this store never had is a no-op.
150
- [OperationEventTypes.Removed]: (state, payload) => {
151
- const byId = { ...state.byId };
152
- for (const id of payload.operationIds)
153
- delete byId[id];
154
- return makeState(byId);
155
- },
156
- },
157
- actions: ({ post }) => ({
158
- cancel: (operationId) => post(OperationRoutes.Cancel, { payload: { operationId } }),
159
- dismiss: (operationId) => post(OperationRoutes.Dismiss, { payload: { operationId } }),
160
- wait: (operationId) => post(OperationRoutes.Wait, { payload: { operationId } }),
161
- clearFinished: () =>
162
- // Forward THIS store's own configured scope (Finding 1, generic-library audit) — the same
163
- // key the LIST snapshot payload already carries above. No local mutation here any more
164
- // (Finding 4): the host's OPERATION_REMOVED is the ONLY thing that removes a row now, which
165
- // is also what makes the scope threading safe to add — nothing here can diverge from what
166
- // the host actually cleared.
167
- post(OperationRoutes.ClearFinished, {
168
- payload: options.scope !== undefined ? { scope: options.scope } : undefined,
169
- }),
170
- resume: (operationId) => post(OperationRoutes.Resume, { payload: { operationId } }),
171
- }),
172
- scope: options.scope,
173
- bridge: options.bridge,
174
- bus: options.bus,
175
- });
176
- }
177
- /**
178
- * The client side of the operations primitive (design §4.6, `OperationsFacade` +
179
- * `IOperationRegistry`): snapshots via `LIST` on first subscribe, then folds `OPERATION_UPDATED` by
180
- * id — one subscription however many components read it, and a late mounter renders CURRENT state
181
- * because the host is authoritative (the store primitive's own late-mounter case is now
182
- * host-backed end to end). `running`/`waiting`/`finished` are selectors an activity panel or status
183
- * bar reads directly: `useShenoraOperations((s) => s.waiting)` for the one "needs you" bucket
184
- * (every entry in it reached the band the same way, so there is no sub-case to filter for; read
185
- * `waitReason` for WHY it is waiting). Bound to the default module/no scope — use
186
- * {@link createOperationsStore} directly for a renamed module or a scope-filtered instance.
187
- *
188
- * Headless, per D13: no component, no UI opinion, no `ProcessType`-style enum — what an operation
189
- * IS stays the app's `kind` string; this only carries the uniform lifecycle around it.
190
- */
191
- export const useShenoraOperations = createOperationsStore();