@jarenjs/app 0.34.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 +197 -0
- package/dist/types/actions.d.ts +89 -0
- package/dist/types/app.d.ts +293 -0
- package/dist/types/diagnostics.d.ts +67 -0
- package/dist/types/docstore.d.ts +52 -0
- package/dist/types/errors.d.ts +178 -0
- package/dist/types/focus.d.ts +89 -0
- package/dist/types/forms.d.ts +122 -0
- package/dist/types/index.d.ts +14 -0
- package/dist/types/splitter.d.ts +37 -0
- package/dist/types/tasks.d.ts +129 -0
- package/docs/APP-FORMAT.md +804 -0
- package/docs/TASKS.md +254 -0
- package/package.json +57 -0
- package/schemas/jaren-app.draft-07.schema.json +86 -0
- package/schemas/jaren-app.schema.json +86 -0
- package/src/actions.js +167 -0
- package/src/app.js +1622 -0
- package/src/diagnostics.js +77 -0
- package/src/docstore.js +71 -0
- package/src/errors.js +245 -0
- package/src/focus.js +188 -0
- package/src/forms.js +325 -0
- package/src/index.js +16 -0
- package/src/splitter.js +113 -0
- package/src/tasks.js +299 -0
package/src/tasks.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The host half of the async-task convention (docs/TASKS.md).
|
|
4
|
+
*
|
|
5
|
+
* The convention has two halves and this module is deliberately only one
|
|
6
|
+
* of them: **correctness lives in state** — a task slot's monotonic `id`
|
|
7
|
+
* and the completion action's guard reject stale responses — while
|
|
8
|
+
* **cancellation and concurrency live here**, as an optimization that
|
|
9
|
+
* stops wasting the wire. An aborted fetch may already have resolved and
|
|
10
|
+
* its dispatch may already be queued, so a host that only aborts is
|
|
11
|
+
* still wrong; the state-side guard is the guarantee.
|
|
12
|
+
*
|
|
13
|
+
* No timers, no state beyond the per-slot records, no dependencies
|
|
14
|
+
* (`AbortController` is platform).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { toError, isErrorSafely, safeErrorMessage } from './errors.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read a rejection value's `name` without trusting it: `typeof` is
|
|
21
|
+
* untrappable, the property read is guarded — a revoked proxy or a
|
|
22
|
+
* throwing accessor classifies as "not an AbortError".
|
|
23
|
+
* @param {unknown} err
|
|
24
|
+
* @returns {string | null}
|
|
25
|
+
*/
|
|
26
|
+
function safeName(err) {
|
|
27
|
+
if (err === null || (typeof err !== 'object' && typeof err !== 'function')) return null;
|
|
28
|
+
try {
|
|
29
|
+
const name = /** @type {any} */ (err).name;
|
|
30
|
+
return typeof name === 'string' ? name : null;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The `{ id, error }` payload string for a rejection, TOTAL for every
|
|
39
|
+
* value: primitives stringify verbatim (the established payload
|
|
40
|
+
* contract); Errors project their message through the safe accessor;
|
|
41
|
+
* objects, functions and symbols go through the normalizer — no host
|
|
42
|
+
* `toString`/`Symbol.toPrimitive` is ever invoked.
|
|
43
|
+
* @param {unknown} err
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function rejectionText(err) {
|
|
47
|
+
if (isErrorSafely(err)) return safeErrorMessage(err);
|
|
48
|
+
if (err === null
|
|
49
|
+
|| (typeof err !== 'object' && typeof err !== 'function' && typeof err !== 'symbol')) {
|
|
50
|
+
return String(err);
|
|
51
|
+
}
|
|
52
|
+
return toError(err).message;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The host's task function, typically wrapping `fetch`. A synchronous
|
|
57
|
+
* return is allowed — the effect settles every result through one
|
|
58
|
+
* uniform promise boundary either way.
|
|
59
|
+
* @callback TaskRun
|
|
60
|
+
* @param {any} props - The effect's `with` value, verbatim.
|
|
61
|
+
* @param {AbortSignal} signal - Aborted when the slot's concurrency mode
|
|
62
|
+
* supersedes this task, when the host cancels the slot, or when the
|
|
63
|
+
* effect is disposed; pass it to `fetch` (or ignore it — the
|
|
64
|
+
* state-side id guard stays correct either way).
|
|
65
|
+
* @returns {any | PromiseLike<any>} The JSON result, or a promise of it.
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Per-slot concurrency modes (APP-FORMAT §9.2):
|
|
70
|
+
*
|
|
71
|
+
* - `"switch"` (default) — starting a task aborts the slot's
|
|
72
|
+
* in-flight predecessor; the newest request wins.
|
|
73
|
+
* - `"exhaust"` — while the slot has an in-flight task, new starts are
|
|
74
|
+
* ignored entirely (nothing dispatched) — the mode for a
|
|
75
|
+
* non-idempotent commit where a double-click must not double-run.
|
|
76
|
+
* - `"concat"` — new starts queue and run one after another, in
|
|
77
|
+
* order — only for deliberately ordered commands.
|
|
78
|
+
* - `"parallel"` — every start runs concurrently; the consumer owns
|
|
79
|
+
* the merge rule.
|
|
80
|
+
*
|
|
81
|
+
* Whatever the mode, correctness stays visible in JSON state: the task
|
|
82
|
+
* slot's monotonic `id` and the completion action's guard remain the
|
|
83
|
+
* authority on which response may land.
|
|
84
|
+
* @typedef {'switch' | 'exhaust' | 'concat' | 'parallel'} TaskMode
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @typedef {Object} TaskEffectOptions
|
|
89
|
+
* @property {TaskMode} [mode] - The per-slot concurrency mode
|
|
90
|
+
* (default `"switch"`).
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The effect handler returned by {@link createTaskEffect}, with its
|
|
95
|
+
* host-side controls.
|
|
96
|
+
* @typedef {((props: any, dispatch: (name: string, payload?: any) => void) => void) & {
|
|
97
|
+
* cancel: (slot?: string) => void,
|
|
98
|
+
* cancelAll: () => void,
|
|
99
|
+
* dispose: () => void,
|
|
100
|
+
* }} TaskEffect
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Package `run` as a registered effect handler implementing the
|
|
105
|
+
* async-task convention. The effect's `with` props (all JSON):
|
|
106
|
+
*
|
|
107
|
+
* - `id` (REQUIRED) — the task identity, echoed back verbatim in the
|
|
108
|
+
* completion payload for the state-side guard;
|
|
109
|
+
* - `done` (REQUIRED) — the action dispatched on settle;
|
|
110
|
+
* - `fail` (OPTIONAL, string) — the action for rejections; absent,
|
|
111
|
+
* rejections dispatch `done` with `{ id, error }` instead of
|
|
112
|
+
* `{ id, result }` — one completion action guarding on
|
|
113
|
+
* `$payload.error` is the query-friendliest shape;
|
|
114
|
+
* - `slot` (OPTIONAL, string) — the concurrency key, default `""`;
|
|
115
|
+
* what a new start does to the slot's in-flight task is the
|
|
116
|
+
* effect's `mode` (see {@link TaskMode});
|
|
117
|
+
* - anything else `run` needs (a URL, a query, ...).
|
|
118
|
+
*
|
|
119
|
+
* Settlement, exactly: `run` is invoked through a uniform promise
|
|
120
|
+
* boundary, so a synchronous throw and a non-promise return settle
|
|
121
|
+
* through the same path as a rejection/resolution. A resolution
|
|
122
|
+
* dispatches `done` with `{ id, result }`; an abort rejection
|
|
123
|
+
* (`err.name === "AbortError"`) dispatches **nothing** — a superseded
|
|
124
|
+
* task is dead by design, its successor's dispatch carries the story;
|
|
125
|
+
* 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.
|
|
128
|
+
* A malformed `id`/`done`/`fail`/`slot` is a host programming error:
|
|
129
|
+
* the handler throws a `TypeError`, which the loop reports as `JA2007`.
|
|
130
|
+
* Settlement is TOTAL for every rejection value (hostile accessors,
|
|
131
|
+
* revoked proxies included) and never creates an unhandled rejection
|
|
132
|
+
* from framework code; a settlement dispatch that itself throws (a
|
|
133
|
+
* rethrowing error sink surfacing at the dispatch boundary) is
|
|
134
|
+
* re-raised on its own microtask so the host's global error handling
|
|
135
|
+
* observes it.
|
|
136
|
+
*
|
|
137
|
+
* Host-side controls on the returned handler:
|
|
138
|
+
*
|
|
139
|
+
* - `cancel(slot)` — abort the slot's in-flight task(s) and discard
|
|
140
|
+
* its queued (`concat`) starts;
|
|
141
|
+
* - `cancelAll()` — `cancel` for every slot;
|
|
142
|
+
* - `dispose()` — terminal: `cancelAll()` plus a permanent guard —
|
|
143
|
+
* late settlements can no longer dispatch, and new starts are
|
|
144
|
+
* ignored. Idempotent. `app.destroy()` calls it automatically for
|
|
145
|
+
* every registered handler exposing it.
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* createApp(doc, {
|
|
149
|
+
* effects: {
|
|
150
|
+
* http: createTaskEffect((props, signal) =>
|
|
151
|
+
* fetch(props.url, { signal }).then((r) => r.json())),
|
|
152
|
+
* },
|
|
153
|
+
* });
|
|
154
|
+
*
|
|
155
|
+
* @param {TaskRun} run
|
|
156
|
+
* @param {TaskEffectOptions} [options]
|
|
157
|
+
* @returns {TaskEffect}
|
|
158
|
+
*/
|
|
159
|
+
export function createTaskEffect(run, options = {}) {
|
|
160
|
+
const mode = options.mode ?? 'switch';
|
|
161
|
+
if (mode !== 'switch' && mode !== 'exhaust' && mode !== 'concat' && mode !== 'parallel') {
|
|
162
|
+
throw new TypeError(`createTaskEffect: unknown mode '${String(mode)}'`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Per-slot bookkeeping: the in-flight controllers and, for `concat`,
|
|
167
|
+
* the pending starts.
|
|
168
|
+
* @type {Map<string, { active: Set<AbortController>, queue: Array<[any, (name: string, payload?: any) => void]> }>}
|
|
169
|
+
*/
|
|
170
|
+
const slots = new Map();
|
|
171
|
+
let disposed = false;
|
|
172
|
+
|
|
173
|
+
/** @param {string} slot */
|
|
174
|
+
function slotRecord(slot) {
|
|
175
|
+
let record = slots.get(slot);
|
|
176
|
+
if (record === undefined) {
|
|
177
|
+
record = { active: new Set(), queue: [] };
|
|
178
|
+
slots.set(slot, record);
|
|
179
|
+
}
|
|
180
|
+
return record;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Launch one task on a slot through the uniform promise boundary.
|
|
185
|
+
* @param {{ active: Set<AbortController>, queue: Array<[any, (name: string, payload?: any) => void]> }} record
|
|
186
|
+
* @param {any} props
|
|
187
|
+
* @param {(name: string, payload?: any) => void} dispatch
|
|
188
|
+
*/
|
|
189
|
+
function launch(record, props, dispatch) {
|
|
190
|
+
const controller = new AbortController();
|
|
191
|
+
record.active.add(controller);
|
|
192
|
+
|
|
193
|
+
/** Release this task's controller and, for `concat`, start the next. */
|
|
194
|
+
const settle = () => {
|
|
195
|
+
record.active.delete(controller);
|
|
196
|
+
if (mode === 'concat' && !disposed && record.active.size === 0 && record.queue.length > 0) {
|
|
197
|
+
const next = /** @type {[any, (name: string, payload?: any) => void]} */ (record.queue.shift());
|
|
198
|
+
launch(record, next[0], next[1]);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// the uniform boundary: a synchronous throw from `run` and a
|
|
203
|
+
// non-promise return settle exactly like a rejection/resolution
|
|
204
|
+
new Promise((resolve) => { resolve(run(props, controller.signal)); }).then(
|
|
205
|
+
(result) => {
|
|
206
|
+
settle();
|
|
207
|
+
if (disposed) return;
|
|
208
|
+
try {
|
|
209
|
+
dispatch(props.done, { id: props.id, result });
|
|
210
|
+
}
|
|
211
|
+
catch (thrown) {
|
|
212
|
+
queueMicrotask(() => { throw thrown; });
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
(err) => {
|
|
216
|
+
settle();
|
|
217
|
+
if (disposed) return;
|
|
218
|
+
// abort classification is TOTAL and classifies the REJECTION
|
|
219
|
+
// VALUE only (`safeName` guards the read): the signal state
|
|
220
|
+
// must not suppress — a superseded task's non-abort failure
|
|
221
|
+
// still dispatches by contract, and only the state-side id
|
|
222
|
+
// guard rejects it. A hostile value never breaks settlement.
|
|
223
|
+
if (safeName(err) === 'AbortError') return;
|
|
224
|
+
const error = rejectionText(err);
|
|
225
|
+
// a settlement dispatch that itself throws (a rethrowing error
|
|
226
|
+
// sink surfacing at the dispatch boundary) must not become an
|
|
227
|
+
// unobservable promise rejection: it is re-raised on its own
|
|
228
|
+
// microtask so the host's global error handling observes it
|
|
229
|
+
try {
|
|
230
|
+
dispatch(props.fail ?? props.done, { id: props.id, error });
|
|
231
|
+
}
|
|
232
|
+
catch (thrown) {
|
|
233
|
+
queueMicrotask(() => { throw thrown; });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** @type {any} */
|
|
239
|
+
const taskEffect = function taskEffect(props, dispatch) {
|
|
240
|
+
if (props === null || typeof props !== 'object'
|
|
241
|
+
|| props.id === undefined || typeof props.done !== 'string') {
|
|
242
|
+
throw new TypeError(
|
|
243
|
+
'createTaskEffect: the effect props must carry an "id" and a "done" action name');
|
|
244
|
+
}
|
|
245
|
+
if (props.fail !== undefined && typeof props.fail !== 'string') {
|
|
246
|
+
throw new TypeError('createTaskEffect: "fail" must be an action name');
|
|
247
|
+
}
|
|
248
|
+
if (props.slot !== undefined && typeof props.slot !== 'string') {
|
|
249
|
+
throw new TypeError('createTaskEffect: "slot" must be a string');
|
|
250
|
+
}
|
|
251
|
+
if (disposed) return;
|
|
252
|
+
const record = slotRecord(props.slot ?? '');
|
|
253
|
+
|
|
254
|
+
if (record.active.size > 0) {
|
|
255
|
+
if (mode === 'exhaust') return;
|
|
256
|
+
if (mode === 'concat') {
|
|
257
|
+
record.queue.push([props, dispatch]);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (mode === 'switch') {
|
|
261
|
+
for (const controller of record.active) controller.abort();
|
|
262
|
+
record.active.clear();
|
|
263
|
+
}
|
|
264
|
+
// parallel: fall through, the new task joins the slot
|
|
265
|
+
}
|
|
266
|
+
launch(record, props, dispatch);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Abort a slot's in-flight task(s) and discard its queued starts.
|
|
271
|
+
* Aborted settlements dispatch nothing (the AbortError rule).
|
|
272
|
+
* @param {string} [slot]
|
|
273
|
+
*/
|
|
274
|
+
taskEffect.cancel = function cancel(slot = '') {
|
|
275
|
+
const record = slots.get(slot);
|
|
276
|
+
if (record === undefined) return;
|
|
277
|
+
record.queue.length = 0;
|
|
278
|
+
for (const controller of record.active) controller.abort();
|
|
279
|
+
record.active.clear();
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/** `cancel` every slot. */
|
|
283
|
+
taskEffect.cancelAll = function cancelAll() {
|
|
284
|
+
for (const slot of slots.keys()) taskEffect.cancel(slot);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Terminal: cancel everything and permanently prevent both new starts
|
|
289
|
+
* and late-settlement dispatches. Idempotent.
|
|
290
|
+
*/
|
|
291
|
+
taskEffect.dispose = function dispose() {
|
|
292
|
+
if (disposed) return;
|
|
293
|
+
disposed = true;
|
|
294
|
+
taskEffect.cancelAll();
|
|
295
|
+
slots.clear();
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
return taskEffect;
|
|
299
|
+
}
|