@jarenjs/app 0.34.0 → 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 +2 -2
- package/dist/types/tasks.d.ts +29 -4
- package/docs/APP-FORMAT.md +23 -0
- package/docs/TASKS.md +5 -3
- package/package.json +4 -4
- package/src/tasks.js +48 -5
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 }`
|
|
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
|
|
package/dist/types/tasks.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
97
|
-
* the
|
|
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,
|
package/docs/APP-FORMAT.md
CHANGED
|
@@ -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.
|
|
109
|
-
|
|
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
|
|
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.
|
|
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.
|
|
54
|
-
"@jarenjs/json": "^0.
|
|
55
|
-
"@jarenjs/view": "^0.
|
|
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
|
|
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
|
|
127
|
-
* the
|
|
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 =
|
|
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
|