@jarenjs/app 0.34.2 → 0.43.1

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
@@ -94,7 +94,7 @@ effects: {
94
94
  }
95
95
  ```
96
96
 
97
- The helper aborts a slot's in-flight predecessor (`mode: "switch"`, the default — `"exhaust"`, `"concat"` and `"parallel"` pick the other per-slot concurrency semantics), dispatches `done` with `{ id, result }` on resolve and `fail ?? done` with `{ id, error }` (a string) on failure, and dispatches nothing for an abort. The abort is only an optimization — an aborted request may already have resolved — so the state-side id guard is the guarantee: out-of-order and polling responses are rejected by construction. The handler exposes `cancel(slot)`/`cancelAll()`/`dispose()`; `app.destroy()` disposes it automatically. The full convention, with a runnable worked example the test suite executes verbatim, is [docs/TASKS.md](docs/TASKS.md).
97
+ The helper aborts a slot's in-flight predecessor (`mode: "switch"`, the default — `"exhaust"`, `"concat"` and `"parallel"` pick the other per-slot concurrency semantics), dispatches `done` with `{ id, result }` on resolve and `fail ?? done` with `{ id, error }` on failure — `error` a string by default, or the JSON an optional `projectError(err, props)` returns (the door for a structured HTTP failure: status, code, safe details; anything non-JSON falls back to the string) — and dispatches nothing for an abort. The abort is only an optimization — an aborted request may already have resolved — so the state-side id guard is the guarantee: out-of-order and polling responses are rejected by construction. The handler exposes `cancel(slot)`/`cancelAll()`/`dispose()`; `app.destroy()` disposes it automatically. The full convention, with a runnable worked example the test suite executes verbatim, is [docs/TASKS.md](docs/TASKS.md).
98
98
 
99
99
  ## One FIFO queue, observable transactions
100
100
 
@@ -186,7 +186,7 @@ renderToString(createApp(doc).getVnode());
186
186
 
187
187
  ## API
188
188
 
189
- `createApp(appDoc, options)` → `{ dispatch(name, payload?), getState(), getVnode(), render(), subscribe(listener), observe(observer), stop(), destroy() }`
189
+ `createApp(appDoc, options)` → `{ dispatch(name, payload?), getState(), setState(next), getVnode(), render(), subscribe(listener), observe(observer), stop(), destroy() }`
190
190
 
191
191
  Also exported: `compileActions`, `compileSubs`, `createFormView`, `createFormActions`, `formEventFields`, `createTaskEffect`, `createFocusEffect`, `createTransactionLog`, `createSplitterWidget`, `createDocStore`, `encodeShare`, `decodeShare`, and the error classes (`AppCompileError`, `AppRuntimeError`, `HostValueError`, `toError`, `APP_CODES`).
192
192
 
@@ -9,8 +9,8 @@
9
9
  * its dispatch may already be queued, so a host that only aborts is
10
10
  * still wrong; the state-side guard is the guarantee.
11
11
  *
12
- * No timers, no state beyond the per-slot records, no dependencies
13
- * (`AbortController` is platform).
12
+ * No timers, no state beyond the per-slot records, no third-party
13
+ * dependencies (`AbortController` is platform).
14
14
  */
15
15
  export type TaskRun = (props: any, signal: AbortSignal) => any | PromiseLike<any>;
16
16
  export type TaskMode = 'switch' | 'exhaust' | 'concat' | 'parallel';
@@ -20,6 +20,19 @@ export type TaskEffectOptions = {
20
20
  * (default `"switch"`).
21
21
  */
22
22
  mode?: TaskMode;
23
+ /**
24
+ * - Project
25
+ * a NON-abort rejection into the JSON `error` member of the `{ id,
26
+ * error }` settlement payload — the door for a structured HTTP failure
27
+ * (status, code, safe details) that the default string projection would
28
+ * flatten. Receives the rejection value and the effect props verbatim.
29
+ * An `AbortError` never reaches it (cancellation dispatches nothing).
30
+ * Its result must be JSON: a projector that throws, returns `undefined`
31
+ * or returns a value JSON cannot carry (a host object, a function, a
32
+ * cycle ...) falls back to the string, so settlement stays total and no
33
+ * host object enters state through it.
34
+ */
35
+ projectError?: (err: unknown, props: any) => any;
23
36
  };
24
37
  export type TaskEffect = ((props: any, dispatch: (name: string, payload?: any) => void) => void) & {
25
38
  cancel: (slot?: string) => void;
@@ -60,6 +73,16 @@ export type TaskEffect = ((props: any, dispatch: (name: string, payload?: any) =
60
73
  * @typedef {Object} TaskEffectOptions
61
74
  * @property {TaskMode} [mode] - The per-slot concurrency mode
62
75
  * (default `"switch"`).
76
+ * @property {(err: unknown, props: any) => any} [projectError] - Project
77
+ * a NON-abort rejection into the JSON `error` member of the `{ id,
78
+ * error }` settlement payload — the door for a structured HTTP failure
79
+ * (status, code, safe details) that the default string projection would
80
+ * flatten. Receives the rejection value and the effect props verbatim.
81
+ * An `AbortError` never reaches it (cancellation dispatches nothing).
82
+ * Its result must be JSON: a projector that throws, returns `undefined`
83
+ * or returns a value JSON cannot carry (a host object, a function, a
84
+ * cycle ...) falls back to the string, so settlement stays total and no
85
+ * host object enters state through it.
63
86
  */
64
87
  /**
65
88
  * The effect handler returned by {@link createTaskEffect}, with its
@@ -93,8 +116,10 @@ export type TaskEffect = ((props: any, dispatch: (name: string, payload?: any) =
93
116
  * (`err.name === "AbortError"`) dispatches **nothing** — a superseded
94
117
  * task is dead by design, its successor's dispatch carries the story;
95
118
  * any other rejection dispatches `fail ?? done` with `{ id, error }`
96
- * where `error` is a string, never an Error object JSON only crosses
97
- * the boundary. After `dispose()` no settlement dispatches anything.
119
+ * where `error` is a string or, with `options.projectError`, the JSON
120
+ * value the projector returned never an Error object: JSON only
121
+ * crosses the boundary. After `dispose()` no settlement dispatches
122
+ * anything.
98
123
  * A malformed `id`/`done`/`fail`/`slot` is a host programming error:
99
124
  * the handler throws a `TypeError`, which the loop reports as `JA2007`.
100
125
  * Settlement is TOTAL for every rejection value (hostile accessors,
@@ -647,6 +647,29 @@ automatically by `app.destroy()`). `run` is invoked through a uniform
647
647
  promise boundary: a synchronous throw and a non-promise return settle
648
648
  through the same path as a rejection/resolution.
649
649
 
650
+ ### 9.3 Structured failure — `projectError`
651
+
652
+ By default a non-abort rejection settles as `{ id, error }` with `error`
653
+ a **string**. That flattens what an HTTP host knows about a failure —
654
+ status, a stable error code, safe details — so `createTaskEffect` takes
655
+ an optional `projectError(err, props) => JSON` that produces the `error`
656
+ member instead. The rules that keep the door narrow:
657
+
658
+ - an `AbortError` never reaches the projector — cancellation dispatches
659
+ nothing, as before;
660
+ - the projector receives the rejection value and the effect props
661
+ verbatim (so it can name the operation the failure belongs to);
662
+ - its result must be JSON. A projector that throws, returns `undefined`
663
+ (declines) or returns a value JSON cannot carry — an `Error`, a
664
+ `Response`, a function, a class instance, a cycle, a non-finite number
665
+ — falls back to the string projection. Settlement therefore stays
666
+ total, and no host object enters state through this path;
667
+ - a non-function `projectError` is a `TypeError` at construction (a host
668
+ programming error).
669
+
670
+ The completion action stores whatever it is handed; the state schema is
671
+ where the projected shape is pinned.
672
+
650
673
  ## 10. Errors
651
674
 
652
675
  ### 10.1 The host-failure normalization policy
package/docs/TASKS.md CHANGED
@@ -105,8 +105,10 @@ ignored — the double-click-safe commit mode), `"concat"` (starts queue
105
105
  and run strictly in order) or `"parallel"` (APP-FORMAT §9.2). The
106
106
  handler carries the host-side controls `cancel(slot)`, `cancelAll()`
107
107
  and `dispose()` — after `dispose()` (which `app.destroy()` calls
108
- automatically) no late settlement can dispatch. The effect-props
109
- convention (all JSON):
108
+ automatically) no late settlement can dispatch. `options.projectError`
109
+ opens the structured-failure door: an HTTP host can settle `{ id,
110
+ error: { status, code, ... } }` instead of a flattened string
111
+ (APP-FORMAT §9.3). The effect-props convention (all JSON):
110
112
 
111
113
  | Prop | | Meaning |
112
114
  |---|---|---|
@@ -123,7 +125,7 @@ Settlement semantics, exactly:
123
125
  | start | The slot's in-flight controller (if any) is aborted; a fresh one is stored; `run(props, signal)` is called. |
124
126
  | resolve | `dispatch(done, { id, result })`. |
125
127
  | reject, `err.name === "AbortError"` | **Nothing.** A superseded task is dead by design; its successor's dispatch carries the story. |
126
- | reject, anything else | `dispatch(fail ?? done, { id, error })` — `error` is a **string**, never an Error object; JSON only crosses the boundary. |
128
+ | reject, anything else | `dispatch(fail ?? done, { id, error })` — `error` is a **string** by default, or the JSON value `options.projectError(err, props)` returned (APP-FORMAT §9.3); never an Error object JSON only crosses the boundary. A projector that throws, declines (`undefined`) or returns non-JSON falls back to the string. |
127
129
  | settle | The task's controller is released; a `concat` slot starts its next queued task. |
128
130
  | after `dispose()` | **Nothing** — a late settlement can no longer dispatch. |
129
131
  | malformed `id`/`done`/`fail`/`slot` | A `TypeError` from the handler — a host programming error, reported by the loop as `JA2007`. |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/app",
3
3
  "private": false,
4
- "version": "0.34.2",
4
+ "version": "0.43.1",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -50,8 +50,8 @@
50
50
  "prepack": "npm run build:types"
51
51
  },
52
52
  "dependencies": {
53
- "@jarenjs/core": "^0.34.2",
54
- "@jarenjs/json": "^0.34.2",
55
- "@jarenjs/view": "^0.34.2"
53
+ "@jarenjs/core": "^0.43.1",
54
+ "@jarenjs/json": "^0.43.1",
55
+ "@jarenjs/view": "^0.43.1"
56
56
  }
57
57
  }
package/src/tasks.js CHANGED
@@ -10,10 +10,12 @@
10
10
  * its dispatch may already be queued, so a host that only aborts is
11
11
  * still wrong; the state-side guard is the guarantee.
12
12
  *
13
- * No timers, no state beyond the per-slot records, no dependencies
14
- * (`AbortController` is platform).
13
+ * No timers, no state beyond the per-slot records, no third-party
14
+ * dependencies (`AbortController` is platform).
15
15
  */
16
16
 
17
+ import { isJsonValue } from '@jarenjs/core/object';
18
+
17
19
  import { toError, isErrorSafely, safeErrorMessage } from './errors.js';
18
20
 
19
21
  /**
@@ -52,6 +54,31 @@ function rejectionText(err) {
52
54
  return toError(err).message;
53
55
  }
54
56
 
57
+ /**
58
+ * The `{ id, error }` payload for a non-abort rejection: the host's
59
+ * `projectError` result when it produced JSON, the string projection
60
+ * otherwise. TOTAL like everything on this path — a projector that
61
+ * throws, returns `undefined` (declines) or returns anything JSON cannot
62
+ * carry (an `Error`, a `Response`, a function, a cycle ...) falls back to
63
+ * the string, so no host object crosses into state through this door.
64
+ * @param {((err: unknown, props: any) => any) | null} projectError
65
+ * @param {unknown} err
66
+ * @param {any} props
67
+ * @returns {any}
68
+ */
69
+ function rejectionPayload(projectError, err, props) {
70
+ if (projectError !== null) {
71
+ try {
72
+ const projected = projectError(err, props);
73
+ if (projected !== undefined && isJsonValue(projected)) return projected;
74
+ }
75
+ catch {
76
+ // fall through to the string projection
77
+ }
78
+ }
79
+ return rejectionText(err);
80
+ }
81
+
55
82
  /**
56
83
  * The host's task function, typically wrapping `fetch`. A synchronous
57
84
  * return is allowed — the effect settles every result through one
@@ -88,6 +115,16 @@ function rejectionText(err) {
88
115
  * @typedef {Object} TaskEffectOptions
89
116
  * @property {TaskMode} [mode] - The per-slot concurrency mode
90
117
  * (default `"switch"`).
118
+ * @property {(err: unknown, props: any) => any} [projectError] - Project
119
+ * a NON-abort rejection into the JSON `error` member of the `{ id,
120
+ * error }` settlement payload — the door for a structured HTTP failure
121
+ * (status, code, safe details) that the default string projection would
122
+ * flatten. Receives the rejection value and the effect props verbatim.
123
+ * An `AbortError` never reaches it (cancellation dispatches nothing).
124
+ * Its result must be JSON: a projector that throws, returns `undefined`
125
+ * or returns a value JSON cannot carry (a host object, a function, a
126
+ * cycle ...) falls back to the string, so settlement stays total and no
127
+ * host object enters state through it.
91
128
  */
92
129
 
93
130
  /**
@@ -123,8 +160,10 @@ function rejectionText(err) {
123
160
  * (`err.name === "AbortError"`) dispatches **nothing** — a superseded
124
161
  * task is dead by design, its successor's dispatch carries the story;
125
162
  * any other rejection dispatches `fail ?? done` with `{ id, error }`
126
- * where `error` is a string, never an Error object JSON only crosses
127
- * the boundary. After `dispose()` no settlement dispatches anything.
163
+ * where `error` is a string or, with `options.projectError`, the JSON
164
+ * value the projector returned never an Error object: JSON only
165
+ * crosses the boundary. After `dispose()` no settlement dispatches
166
+ * anything.
128
167
  * A malformed `id`/`done`/`fail`/`slot` is a host programming error:
129
168
  * the handler throws a `TypeError`, which the loop reports as `JA2007`.
130
169
  * Settlement is TOTAL for every rejection value (hostile accessors,
@@ -161,6 +200,10 @@ export function createTaskEffect(run, options = {}) {
161
200
  if (mode !== 'switch' && mode !== 'exhaust' && mode !== 'concat' && mode !== 'parallel') {
162
201
  throw new TypeError(`createTaskEffect: unknown mode '${String(mode)}'`);
163
202
  }
203
+ const projectError = options.projectError ?? null;
204
+ if (projectError !== null && typeof projectError !== 'function') {
205
+ throw new TypeError('createTaskEffect: "projectError" must be a function');
206
+ }
164
207
 
165
208
  /**
166
209
  * Per-slot bookkeeping: the in-flight controllers and, for `concat`,
@@ -221,7 +264,7 @@ export function createTaskEffect(run, options = {}) {
221
264
  // still dispatches by contract, and only the state-side id
222
265
  // guard rejects it. A hostile value never breaks settlement.
223
266
  if (safeName(err) === 'AbortError') return;
224
- const error = rejectionText(err);
267
+ const error = rejectionPayload(projectError, err, props);
225
268
  // a settlement dispatch that itself throws (a rethrowing error
226
269
  // sink surfacing at the dispatch boundary) must not become an
227
270
  // unobservable promise rejection: it is re-raised on its own