@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/docs/TASKS.md
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# Async tasks — the staleness-rejection convention
|
|
2
|
+
|
|
3
|
+
Module: `@jarenjs/app`. This document is the async-task **convention**:
|
|
4
|
+
how an app document performs asynchronous work — request identity,
|
|
5
|
+
stale-response rejection, cancellation — plus the one shipped helper,
|
|
6
|
+
[`createTaskEffect`](../src/tasks.js), that removes the host-side
|
|
7
|
+
boilerplate.
|
|
8
|
+
|
|
9
|
+
It is deliberately **pattern-first**: there is no format change and no
|
|
10
|
+
new document member. The correctness mechanism is expressible in
|
|
11
|
+
today's vocabulary, and that is a *feature* of actions-as-queries — a
|
|
12
|
+
task's identity is ordinary state, its guard is an ordinary query, so
|
|
13
|
+
snapshots, replay and the meta-schema all keep working with nothing
|
|
14
|
+
added. First-class async action documents (a disposition that awaits an
|
|
15
|
+
effect before transitioning) remain the roadmap item they already are
|
|
16
|
+
([APP-FORMAT §11](APP-FORMAT.md)).
|
|
17
|
+
|
|
18
|
+
> **The worked example below is executable.** The single `json` code
|
|
19
|
+
> block in this document is a complete app document; the test suite
|
|
20
|
+
> (`test/app/tasks.test.js`) extracts it *from this file*, compiles it,
|
|
21
|
+
> and drives every scenario — happy path, out-of-order staleness, abort,
|
|
22
|
+
> failure routing, polling — against it. These docs cannot drift from
|
|
23
|
+
> the engine.
|
|
24
|
+
|
|
25
|
+
## The two halves, stated precisely
|
|
26
|
+
|
|
27
|
+
**Correctness lives in state; cancellation lives in the host.**
|
|
28
|
+
|
|
29
|
+
- The **state half** is the guarantee: every task slot carries a
|
|
30
|
+
monotonically increasing `id`; the start action increments it; the
|
|
31
|
+
completion action rejects any payload whose `id` is not the current
|
|
32
|
+
one. Out-of-order responses are harmless *by construction*.
|
|
33
|
+
- The **host half** — an `AbortController` per slot, packaged by
|
|
34
|
+
`createTaskEffect` — is an optimization: it stops wasting the wire on
|
|
35
|
+
a superseded request. It is **not** the guarantee: an aborted fetch
|
|
36
|
+
may already have resolved and its completion dispatch may already be
|
|
37
|
+
queued. **A host that only aborts is still wrong.** Keep the id
|
|
38
|
+
guard.
|
|
39
|
+
|
|
40
|
+
## Half 1 — the state convention
|
|
41
|
+
|
|
42
|
+
A task slot in state:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
"tasks": { "list": { "id": 0, "status": "idle", "error": null } }
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**The start action** does two things in one transition: it patches the
|
|
49
|
+
slot (increment `id`, set `status` to `"loading"`, clear `error`) and
|
|
50
|
+
invokes the effect with the **new** id in its props.
|
|
51
|
+
|
|
52
|
+
> ⚠ **The pre-transition-`$` gotcha.** An action's entire result —
|
|
53
|
+
> including every effect's `with` — is computed by the query against
|
|
54
|
+
> the ***pre*-transition** state `$`. The patch is applied before the
|
|
55
|
+
> effects *run*, but their props were already evaluated. So the
|
|
56
|
+
> effect's `with.id` MUST be written as the same increment expression
|
|
57
|
+
> the patch uses — `{ "$add": ["$.tasks.list.id", 1] }` — and never as
|
|
58
|
+
> a read of the patched slot. This is the one place the pattern can
|
|
59
|
+
> silently rot: a `with.id` that reads the slot ships the *old* id, and
|
|
60
|
+
> every completion is then rejected as stale.
|
|
61
|
+
|
|
62
|
+
**The completion action** is dispatched by the host with
|
|
63
|
+
`$payload = { "id", "result" }` on success or `{ "id", "error" }` on
|
|
64
|
+
failure. Guard first: when `$payload.id` differs from the slot's
|
|
65
|
+
current `id`, produce the **empty sequence** — a conditional with no
|
|
66
|
+
else-branch, `{ "$if": [guard, then] }`, which is APP-FORMAT §3.2's
|
|
67
|
+
no-op transition: nothing changes, nothing renders, no subscriber
|
|
68
|
+
fires. Otherwise store the result (or the error) and flip `status`.
|
|
69
|
+
The two payload shapes are told apart with
|
|
70
|
+
`{ "$exists": "$payload.error" }` — an absent member is the empty
|
|
71
|
+
sequence, so `$exists` is the exact "did the host send an error" test.
|
|
72
|
+
|
|
73
|
+
**Polling** composes on top: a subscription whose `when` watches state
|
|
74
|
+
dispatches the start action on an interval. Every poll increments the
|
|
75
|
+
id, so overlapping responses fall out of the same guard — an old poll
|
|
76
|
+
response can never overwrite newer state, with no new machinery.
|
|
77
|
+
|
|
78
|
+
## Half 2 — the shipped helper
|
|
79
|
+
|
|
80
|
+
```javascript
|
|
81
|
+
import { createApp, createTaskEffect } from '@jarenjs/app';
|
|
82
|
+
|
|
83
|
+
createApp(doc, {
|
|
84
|
+
effects: {
|
|
85
|
+
http: createTaskEffect((props, signal) =>
|
|
86
|
+
fetch(props.url, { signal }).then((r) => r.json())),
|
|
87
|
+
},
|
|
88
|
+
subs: {
|
|
89
|
+
every: (props, dispatch) => {
|
|
90
|
+
const id = setInterval(() => dispatch(props.action), props.ms);
|
|
91
|
+
return () => clearInterval(id);
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`createTaskEffect(run, options)` takes the host's async function
|
|
98
|
+
`run(props, signal) => Promise<JSON>` and returns an ordinary effect
|
|
99
|
+
handler. `run` is invoked through a **uniform promise boundary**: a
|
|
100
|
+
synchronous throw and a non-promise return settle through exactly the
|
|
101
|
+
same path as a rejection/resolution. `options.mode` picks the per-slot
|
|
102
|
+
concurrency semantics — `"switch"` (default: a new start aborts the
|
|
103
|
+
slot's in-flight predecessor), `"exhaust"` (duplicate starts are
|
|
104
|
+
ignored — the double-click-safe commit mode), `"concat"` (starts queue
|
|
105
|
+
and run strictly in order) or `"parallel"` (APP-FORMAT §9.2). The
|
|
106
|
+
handler carries the host-side controls `cancel(slot)`, `cancelAll()`
|
|
107
|
+
and `dispose()` — after `dispose()` (which `app.destroy()` calls
|
|
108
|
+
automatically) no late settlement can dispatch. The effect-props
|
|
109
|
+
convention (all JSON):
|
|
110
|
+
|
|
111
|
+
| Prop | | Meaning |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `id` | REQUIRED | The task identity, echoed back verbatim in the completion payload. |
|
|
114
|
+
| `done` | REQUIRED | The action dispatched on settle. |
|
|
115
|
+
| `fail` | OPTIONAL | The action for rejections. Absent: rejections dispatch `done` with `{ id, error }` instead of `{ id, result }` — one completion action guarding on `$payload.error` is the query-friendliest shape, so it is the default. |
|
|
116
|
+
| `slot` | OPTIONAL | The concurrency key (a string), default `""`. What a new start does to the slot's in-flight task is the effect's `mode` (default `"switch"`: it aborts the predecessor). |
|
|
117
|
+
| ... | | Anything else `run` needs (a URL, a query, ...). |
|
|
118
|
+
|
|
119
|
+
Settlement semantics, exactly:
|
|
120
|
+
|
|
121
|
+
| Outcome | Effect |
|
|
122
|
+
|---|---|
|
|
123
|
+
| start | The slot's in-flight controller (if any) is aborted; a fresh one is stored; `run(props, signal)` is called. |
|
|
124
|
+
| resolve | `dispatch(done, { id, result })`. |
|
|
125
|
+
| 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. |
|
|
127
|
+
| settle | The task's controller is released; a `concat` slot starts its next queued task. |
|
|
128
|
+
| after `dispose()` | **Nothing** — a late settlement can no longer dispatch. |
|
|
129
|
+
| malformed `id`/`done`/`fail`/`slot` | A `TypeError` from the handler — a host programming error, reported by the loop as `JA2007`. |
|
|
130
|
+
|
|
131
|
+
Tasks in different slots never touch each other. The helper holds no
|
|
132
|
+
state beyond the per-slot records, uses no timers, and has zero
|
|
133
|
+
dependencies (`AbortController` is platform).
|
|
134
|
+
|
|
135
|
+
## The worked example
|
|
136
|
+
|
|
137
|
+
A list task and a detail task in independent slots, with polling. Both
|
|
138
|
+
completion actions guard on the slot id; the detail task routes
|
|
139
|
+
failures to a dedicated `fail` action, the list task uses the default
|
|
140
|
+
single-completion shape:
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"$app": "0.1",
|
|
145
|
+
"state": {
|
|
146
|
+
"tasks": {
|
|
147
|
+
"list": { "id": 0, "status": "idle", "error": null },
|
|
148
|
+
"detail": { "id": 0, "status": "idle", "error": null }
|
|
149
|
+
},
|
|
150
|
+
"items": [],
|
|
151
|
+
"detail": null,
|
|
152
|
+
"polling": false
|
|
153
|
+
},
|
|
154
|
+
"view": [
|
|
155
|
+
{ "match": "$", "body": ["main", {}, "$.tasks.list.status"] }
|
|
156
|
+
],
|
|
157
|
+
"actions": {
|
|
158
|
+
"loadList": {
|
|
159
|
+
"patch": [
|
|
160
|
+
{ "op": "replace", "path": "/tasks/list/id",
|
|
161
|
+
"value": { "$add": ["$.tasks.list.id", 1] } },
|
|
162
|
+
{ "op": "replace", "path": "/tasks/list/status", "value": "loading" },
|
|
163
|
+
{ "op": "replace", "path": "/tasks/list/error", "value": null }
|
|
164
|
+
],
|
|
165
|
+
"effects": [
|
|
166
|
+
{ "run": "http", "with": {
|
|
167
|
+
"id": { "$add": ["$.tasks.list.id", 1] },
|
|
168
|
+
"done": "listLoaded",
|
|
169
|
+
"slot": "list",
|
|
170
|
+
"url": "/api/items"
|
|
171
|
+
} }
|
|
172
|
+
]
|
|
173
|
+
},
|
|
174
|
+
"listLoaded": {
|
|
175
|
+
"$if": [
|
|
176
|
+
{ "$eq": ["$payload.id", "$.tasks.list.id"] },
|
|
177
|
+
{ "$if": [
|
|
178
|
+
{ "$exists": "$payload.error" },
|
|
179
|
+
{ "patch": [
|
|
180
|
+
{ "op": "replace", "path": "/tasks/list/status", "value": "error" },
|
|
181
|
+
{ "op": "replace", "path": "/tasks/list/error", "value": "$payload.error" }
|
|
182
|
+
] },
|
|
183
|
+
{ "patch": [
|
|
184
|
+
{ "op": "replace", "path": "/tasks/list/status", "value": "done" },
|
|
185
|
+
{ "op": "replace", "path": "/items", "value": "$payload.result" }
|
|
186
|
+
] }
|
|
187
|
+
] }
|
|
188
|
+
]
|
|
189
|
+
},
|
|
190
|
+
"openDetail": {
|
|
191
|
+
"patch": [
|
|
192
|
+
{ "op": "replace", "path": "/tasks/detail/id",
|
|
193
|
+
"value": { "$add": ["$.tasks.detail.id", 1] } },
|
|
194
|
+
{ "op": "replace", "path": "/tasks/detail/status", "value": "loading" },
|
|
195
|
+
{ "op": "replace", "path": "/tasks/detail/error", "value": null }
|
|
196
|
+
],
|
|
197
|
+
"effects": [
|
|
198
|
+
{ "run": "http", "with": {
|
|
199
|
+
"id": { "$add": ["$.tasks.detail.id", 1] },
|
|
200
|
+
"done": "detailLoaded",
|
|
201
|
+
"fail": "detailFailed",
|
|
202
|
+
"slot": "detail",
|
|
203
|
+
"url": { "$concat": ["/api/items/", { "$string": "$payload" }] }
|
|
204
|
+
} }
|
|
205
|
+
]
|
|
206
|
+
},
|
|
207
|
+
"detailLoaded": {
|
|
208
|
+
"$if": [
|
|
209
|
+
{ "$eq": ["$payload.id", "$.tasks.detail.id"] },
|
|
210
|
+
{ "patch": [
|
|
211
|
+
{ "op": "replace", "path": "/tasks/detail/status", "value": "done" },
|
|
212
|
+
{ "op": "replace", "path": "/detail", "value": "$payload.result" }
|
|
213
|
+
] }
|
|
214
|
+
]
|
|
215
|
+
},
|
|
216
|
+
"detailFailed": {
|
|
217
|
+
"$if": [
|
|
218
|
+
{ "$eq": ["$payload.id", "$.tasks.detail.id"] },
|
|
219
|
+
{ "patch": [
|
|
220
|
+
{ "op": "replace", "path": "/tasks/detail/status", "value": "error" },
|
|
221
|
+
{ "op": "replace", "path": "/tasks/detail/error", "value": "$payload.error" }
|
|
222
|
+
] }
|
|
223
|
+
]
|
|
224
|
+
},
|
|
225
|
+
"startPolling": { "patch": [{ "op": "replace", "path": "/polling", "value": true }] },
|
|
226
|
+
"stopPolling": { "patch": [{ "op": "replace", "path": "/polling", "value": false }] }
|
|
227
|
+
},
|
|
228
|
+
"subs": [
|
|
229
|
+
{ "run": "every", "when": "$.polling",
|
|
230
|
+
"with": { "ms": 5000, "action": "loadList" } }
|
|
231
|
+
]
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Walk the staleness scenario through it: `loadList` twice in quick
|
|
236
|
+
succession takes the slot id to `2` and the helper aborts request 1.
|
|
237
|
+
Suppose request 1's response arrives anyway (it had already resolved
|
|
238
|
+
when the abort landed). Its dispatch is `listLoaded` with
|
|
239
|
+
`{ "id": 1, ... }`; the guard compares `1` against the slot's `2`,
|
|
240
|
+
`$if` takes no branch, the action yields the empty sequence — no state
|
|
241
|
+
change, no render, no subscriber. Only request 2's completion, carrying
|
|
242
|
+
the current id, lands. The same holds when polling: every tick is a
|
|
243
|
+
fresh id, so the guard serializes an arbitrary storm of overlapping
|
|
244
|
+
responses down to "latest wins".
|
|
245
|
+
|
|
246
|
+
## What the meta-schema needs
|
|
247
|
+
|
|
248
|
+
Nothing — and that is the point. The convention is ordinary `state`,
|
|
249
|
+
ordinary `actions`, ordinary `effects` invocations; the worked example
|
|
250
|
+
above validates against the shipped
|
|
251
|
+
[`jaren-app.schema.json`](../schemas/jaren-app.schema.json) unchanged.
|
|
252
|
+
A pattern that needed a schema extension would be a format change; this
|
|
253
|
+
one is proof the existing vocabulary already carries request identity
|
|
254
|
+
and staleness rejection.
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jarenjs/app",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.34.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./dist/types/index.d.ts",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/types/index.d.ts",
|
|
12
|
+
"default": "./src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./schemas/*": "./schemas/*",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist/types/",
|
|
19
|
+
"src/",
|
|
20
|
+
"docs/",
|
|
21
|
+
"schemas/"
|
|
22
|
+
],
|
|
23
|
+
"description": "Applications as JSON documents: hyperapp's dispatch loop rebuilt on the Jaren suite — JSLT views, query-document actions, JSON Patch transitions",
|
|
24
|
+
"author": "joham",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/jklarenbeek/jarenjs.git",
|
|
28
|
+
"directory": "packages/app"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=24"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public",
|
|
36
|
+
"registry": "https://registry.npmjs.org/"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"jaren",
|
|
40
|
+
"json",
|
|
41
|
+
"app",
|
|
42
|
+
"framework",
|
|
43
|
+
"hyperapp",
|
|
44
|
+
"jslt",
|
|
45
|
+
"state"
|
|
46
|
+
],
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "npm run build:types",
|
|
49
|
+
"build:types": "tsc -p tsconfig.json",
|
|
50
|
+
"prepack": "npm run build:types"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@jarenjs/core": "^0.34.0",
|
|
54
|
+
"@jarenjs/json": "^0.34.0",
|
|
55
|
+
"@jarenjs/view": "^0.34.0"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://jarenjs.dev/schemas/jaren-app/0.1/draft-07",
|
|
4
|
+
"title": "Jaren app document 0.1",
|
|
5
|
+
"description": "Structural grammar of a complete Jaren application document (see packages/app/docs/APP-FORMAT.md): initial state, a JSLT view stylesheet, named action documents and subscription entries. Composes the published Jaren query and JSLT grammars by reference — register those artifacts alongside this one. The view's OUTPUT vocabulary (vnodes) is published separately as the jaren-vnode schema in @jarenjs/view. This schema is structural validation only: the runtime remains authoritative for semantic rules it cannot express — the transition-object shape an action RETURNS ({state?, patch?, effects?}) is the action's runtime result, not its document form; unknown top-level members are ignored for forward compatibility (APP-FORMAT section 2).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"$app": {
|
|
9
|
+
"description": "The app format version; absent implies 0.1.",
|
|
10
|
+
"const": "0.1"
|
|
11
|
+
},
|
|
12
|
+
"state": {
|
|
13
|
+
"description": "The initial state: any JSON value, treated as immutable by every boundary."
|
|
14
|
+
},
|
|
15
|
+
"view": {
|
|
16
|
+
"description": "The view: a JSLT stylesheet document (bare rule array or envelope form) whose evaluation against the state must produce a single vnode.",
|
|
17
|
+
"allOf": [
|
|
18
|
+
{
|
|
19
|
+
"$ref": "https://jarenjs.dev/schemas/jaren-jslt/0.1/draft-07"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"actions": {
|
|
24
|
+
"description": "Named action documents: Jaren JSON Query documents evaluated with $ bound to the state and the externals $event and $payload.",
|
|
25
|
+
"type": "object",
|
|
26
|
+
"additionalProperties": {
|
|
27
|
+
"$ref": "https://jarenjs.dev/schemas/jaren-query/0.1/draft-07"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"subs": {
|
|
31
|
+
"description": "Subscription entries reconciled against the state after every transition.",
|
|
32
|
+
"type": "array",
|
|
33
|
+
"items": {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"properties": {
|
|
36
|
+
"run": {
|
|
37
|
+
"description": "The registered subscription handler name.",
|
|
38
|
+
"type": "string",
|
|
39
|
+
"minLength": 1
|
|
40
|
+
},
|
|
41
|
+
"with": {
|
|
42
|
+
"description": "Handler props, passed verbatim (never evaluated). Mutually exclusive with \"withQuery\" and \"for\"."
|
|
43
|
+
},
|
|
44
|
+
"when": {
|
|
45
|
+
"description": "Liveness query, asserted by effective boolean value against the state; absent means always live.",
|
|
46
|
+
"allOf": [
|
|
47
|
+
{
|
|
48
|
+
"$ref": "https://jarenjs.dev/schemas/jaren-query/0.1/draft-07"
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
"withQuery": {
|
|
53
|
+
"description": "Dynamic props: a query evaluated against the state per reconciliation; the subscription restarts when its resolved key changes. Under \"for\", \"$item\" is bound per instance. Mutually exclusive with \"with\".",
|
|
54
|
+
"allOf": [
|
|
55
|
+
{
|
|
56
|
+
"$ref": "https://jarenjs.dev/schemas/jaren-query/0.1/draft-07"
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
"key": {
|
|
61
|
+
"description": "Explicit restart key: a query whose resolved value keys the subscription (by value) instead of the resolved props. Requires \"withQuery\" or \"for\"; \"$item\" is bound per instance under \"for\".",
|
|
62
|
+
"allOf": [
|
|
63
|
+
{
|
|
64
|
+
"$ref": "https://jarenjs.dev/schemas/jaren-query/0.1/draft-07"
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
"for": {
|
|
69
|
+
"description": "Fan-out: a query whose result is the item set — one running instance per item key, reconciled per state change. \"with\" is not allowed beside it.",
|
|
70
|
+
"allOf": [
|
|
71
|
+
{
|
|
72
|
+
"$ref": "https://jarenjs.dev/schemas/jaren-query/0.1/draft-07"
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"required": [
|
|
78
|
+
"run"
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"required": [
|
|
84
|
+
"view"
|
|
85
|
+
]
|
|
86
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://jarenjs.dev/schemas/jaren-app/0.1",
|
|
4
|
+
"title": "Jaren app document 0.1",
|
|
5
|
+
"description": "Structural grammar of a complete Jaren application document (see packages/app/docs/APP-FORMAT.md): initial state, a JSLT view stylesheet, named action documents and subscription entries. Composes the published Jaren query and JSLT grammars by reference \u2014 register those artifacts alongside this one. The view's OUTPUT vocabulary (vnodes) is published separately as the jaren-vnode schema in @jarenjs/view. This schema is structural validation only: the runtime remains authoritative for semantic rules it cannot express \u2014 the transition-object shape an action RETURNS ({state?, patch?, effects?}) is the action's runtime result, not its document form; unknown top-level members are ignored for forward compatibility (APP-FORMAT section 2).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"$app": {
|
|
9
|
+
"description": "The app format version; absent implies 0.1.",
|
|
10
|
+
"const": "0.1"
|
|
11
|
+
},
|
|
12
|
+
"state": {
|
|
13
|
+
"description": "The initial state: any JSON value, treated as immutable by every boundary."
|
|
14
|
+
},
|
|
15
|
+
"view": {
|
|
16
|
+
"description": "The view: a JSLT stylesheet document (bare rule array or envelope form) whose evaluation against the state must produce a single vnode.",
|
|
17
|
+
"allOf": [
|
|
18
|
+
{
|
|
19
|
+
"$ref": "https://jarenjs.dev/schemas/jaren-jslt/0.1"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"actions": {
|
|
24
|
+
"description": "Named action documents: Jaren JSON Query documents evaluated with $ bound to the state and the externals $event and $payload.",
|
|
25
|
+
"type": "object",
|
|
26
|
+
"additionalProperties": {
|
|
27
|
+
"$ref": "https://jarenjs.dev/schemas/jaren-query/0.1"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"subs": {
|
|
31
|
+
"description": "Subscription entries reconciled against the state after every transition.",
|
|
32
|
+
"type": "array",
|
|
33
|
+
"items": {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"properties": {
|
|
36
|
+
"run": {
|
|
37
|
+
"description": "The registered subscription handler name.",
|
|
38
|
+
"type": "string",
|
|
39
|
+
"minLength": 1
|
|
40
|
+
},
|
|
41
|
+
"with": {
|
|
42
|
+
"description": "Handler props, passed verbatim (never evaluated). Mutually exclusive with \"withQuery\" and \"for\"."
|
|
43
|
+
},
|
|
44
|
+
"when": {
|
|
45
|
+
"description": "Liveness query, asserted by effective boolean value against the state; absent means always live.",
|
|
46
|
+
"allOf": [
|
|
47
|
+
{
|
|
48
|
+
"$ref": "https://jarenjs.dev/schemas/jaren-query/0.1"
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
"withQuery": {
|
|
53
|
+
"description": "Dynamic props: a query evaluated against the state per reconciliation; the subscription restarts when its resolved key changes. Under \"for\", \"$item\" is bound per instance. Mutually exclusive with \"with\".",
|
|
54
|
+
"allOf": [
|
|
55
|
+
{
|
|
56
|
+
"$ref": "https://jarenjs.dev/schemas/jaren-query/0.1"
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
"key": {
|
|
61
|
+
"description": "Explicit restart key: a query whose resolved value keys the subscription (by value) instead of the resolved props. Requires \"withQuery\" or \"for\"; \"$item\" is bound per instance under \"for\".",
|
|
62
|
+
"allOf": [
|
|
63
|
+
{
|
|
64
|
+
"$ref": "https://jarenjs.dev/schemas/jaren-query/0.1"
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
"for": {
|
|
69
|
+
"description": "Fan-out: a query whose result is the item set \u2014 one running instance per item key, reconciled per state change. \"with\" is not allowed beside it.",
|
|
70
|
+
"allOf": [
|
|
71
|
+
{
|
|
72
|
+
"$ref": "https://jarenjs.dev/schemas/jaren-query/0.1"
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"required": [
|
|
78
|
+
"run"
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"required": [
|
|
84
|
+
"view"
|
|
85
|
+
]
|
|
86
|
+
}
|
package/src/actions.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Compiling the transition vocabulary of an app document: named
|
|
4
|
+
* action documents and subscription entries. Everything here runs once,
|
|
5
|
+
* at `createApp` time — the loop only ever calls compiled closures.
|
|
6
|
+
*
|
|
7
|
+
* An **action document** is a Jaren JSON Query document (QUERY-FORMAT.md)
|
|
8
|
+
* evaluated with `$` bound to the current state and two externals:
|
|
9
|
+
*
|
|
10
|
+
* - `$event` — the serializable event data (`{ type, value, checked,
|
|
11
|
+
* key }`) when the dispatch came from the DOM, else `null`
|
|
12
|
+
* - `$payload` — the binding's `with` value, else `null`
|
|
13
|
+
*
|
|
14
|
+
* It returns a **transition object** (or nothing for a no-op):
|
|
15
|
+
*
|
|
16
|
+
* - `state` — the next state, whole
|
|
17
|
+
* - `patch` — an RFC 6902 JSON Patch applied to the state (after
|
|
18
|
+
* `state`, when both are present)
|
|
19
|
+
* - `effects` — `[{ "run": name, "with"?: props }]` handed to the
|
|
20
|
+
* registered effect handlers
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { compileJsonQuery } from '@jarenjs/json/query';
|
|
24
|
+
import { AppCompileError, toError, safeErrorMessage } from './errors.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The compile-time options shared by every embedded query document.
|
|
28
|
+
* @typedef {Object} ActionCompileOptions
|
|
29
|
+
* @property {(schema: any, docPath: string) => ((value: any) => boolean)} [compileTypeTest]
|
|
30
|
+
* Enables `$valid`/`$assert`/`$as` schema operators inside action
|
|
31
|
+
* documents; typically `createTypeTestCompiler()` from
|
|
32
|
+
* `@jarenjs/validate/query`.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compile the `actions` member of an app document.
|
|
37
|
+
* @param {any} actions
|
|
38
|
+
* @param {ActionCompileOptions} options
|
|
39
|
+
* @returns {Map<string, any>} action name → compiled query
|
|
40
|
+
*/
|
|
41
|
+
export function compileActions(actions, options) {
|
|
42
|
+
if (actions === undefined) return new Map();
|
|
43
|
+
if (actions === null || typeof actions !== 'object' || Array.isArray(actions)) {
|
|
44
|
+
throw new AppCompileError('JA0003',
|
|
45
|
+
'the "actions" member must be an object of named action documents',
|
|
46
|
+
'/actions');
|
|
47
|
+
}
|
|
48
|
+
const map = new Map();
|
|
49
|
+
for (const name in actions) {
|
|
50
|
+
try {
|
|
51
|
+
map.set(name, compileJsonQuery(actions[name], options));
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
const cause = toError(err);
|
|
55
|
+
throw new AppCompileError('JA0004',
|
|
56
|
+
`action '${name}' failed to compile: ${safeErrorMessage(cause)}`,
|
|
57
|
+
`/actions/${name}`, cause);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return map;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A compiled subscription entry.
|
|
65
|
+
* @typedef {Object} CompiledSub
|
|
66
|
+
* @property {string} run - The registered handler name.
|
|
67
|
+
* @property {any} props - The entry's `with` value (`null` when absent).
|
|
68
|
+
* @property {any} when - Compiled liveness query, or `null` (always live).
|
|
69
|
+
* @property {any} withQuery - Compiled props query evaluated against the
|
|
70
|
+
* state (with `$item` bound per instance under `for`), or `null`.
|
|
71
|
+
* @property {any} keyQuery - Compiled restart-key query, or `null` (the
|
|
72
|
+
* key derives from the resolved props by value).
|
|
73
|
+
* @property {any} forQuery - Compiled fan-out query yielding the item
|
|
74
|
+
* set, or `null` (a single-instance subscription).
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compile one dynamic member of a subscription entry as a query,
|
|
79
|
+
* closed-world: `externals` names the only variables the document may
|
|
80
|
+
* leave free (`$item` under `for`, nothing otherwise), so a typo'd
|
|
81
|
+
* variable is JA0008 at compile time instead of an unbound-external
|
|
82
|
+
* error at runtime.
|
|
83
|
+
* @param {any} entry
|
|
84
|
+
* @param {number} i
|
|
85
|
+
* @param {string} member
|
|
86
|
+
* @param {ActionCompileOptions} options
|
|
87
|
+
* @param {readonly string[]} externals
|
|
88
|
+
* @returns {any} the compiled query, or `null` when absent
|
|
89
|
+
*/
|
|
90
|
+
function compileSubQuery(entry, i, member, options, externals) {
|
|
91
|
+
const doc = entry[member];
|
|
92
|
+
if (doc === undefined) return null;
|
|
93
|
+
try {
|
|
94
|
+
return compileJsonQuery(doc, { ...options, externals });
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
const cause = toError(err);
|
|
98
|
+
throw new AppCompileError('JA0008',
|
|
99
|
+
`subscription ${i} ('${entry.run}') has a "${member}" that failed to compile: ${safeErrorMessage(cause)}`,
|
|
100
|
+
`/subs/${i}/${member}`, cause);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Compile the `subs` member of an app document:
|
|
106
|
+
* `[{ "run": name, "with"?: props, "when"?: <EBV query>,
|
|
107
|
+
* "withQuery"?: query, "key"?: query, "for"?: query }]`.
|
|
108
|
+
*
|
|
109
|
+
* `with` is verbatim data; `withQuery` derives the props from the state
|
|
110
|
+
* and makes the subscription DYNAMIC — it restarts when its resolved
|
|
111
|
+
* key changes (`key` overrides the derived-from-props default). `for`
|
|
112
|
+
* fans the declaration out to one instance per item of its result. The
|
|
113
|
+
* combinations that would make one entry ambiguous are JA0008: `with`
|
|
114
|
+
* beside `withQuery`, `with` beside `for`, and `key` without either
|
|
115
|
+
* `withQuery` or `for`.
|
|
116
|
+
* @param {any} subs
|
|
117
|
+
* @param {ActionCompileOptions} options
|
|
118
|
+
* @returns {CompiledSub[]}
|
|
119
|
+
*/
|
|
120
|
+
export function compileSubs(subs, options) {
|
|
121
|
+
if (subs === undefined) return [];
|
|
122
|
+
if (!Array.isArray(subs)) {
|
|
123
|
+
throw new AppCompileError('JA0005',
|
|
124
|
+
'the "subs" member must be an array of subscription entries',
|
|
125
|
+
'/subs');
|
|
126
|
+
}
|
|
127
|
+
return subs.map((entry, i) => {
|
|
128
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)
|
|
129
|
+
|| typeof entry.run !== 'string' || entry.run === '') {
|
|
130
|
+
throw new AppCompileError('JA0006',
|
|
131
|
+
`subscription ${i} must be an object with a non-empty "run" name`,
|
|
132
|
+
`/subs/${i}`);
|
|
133
|
+
}
|
|
134
|
+
let when = null;
|
|
135
|
+
if (entry.when !== undefined) {
|
|
136
|
+
try {
|
|
137
|
+
when = compileJsonQuery(entry.when, options);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
const cause = toError(err);
|
|
141
|
+
throw new AppCompileError('JA0006',
|
|
142
|
+
`subscription ${i} ('${entry.run}') has a "when" that failed to compile: ${safeErrorMessage(cause)}`,
|
|
143
|
+
`/subs/${i}/when`, cause);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (entry.with !== undefined && entry.withQuery !== undefined) {
|
|
147
|
+
throw new AppCompileError('JA0008',
|
|
148
|
+
`subscription ${i} ('${entry.run}') carries both "with" and "withQuery" — one entry, one props source`,
|
|
149
|
+
`/subs/${i}`);
|
|
150
|
+
}
|
|
151
|
+
if (entry.with !== undefined && entry.for !== undefined) {
|
|
152
|
+
throw new AppCompileError('JA0008',
|
|
153
|
+
`subscription ${i} ('${entry.run}') carries "with" beside "for" — fan-out props come from "withQuery" or default to the item`,
|
|
154
|
+
`/subs/${i}`);
|
|
155
|
+
}
|
|
156
|
+
if (entry.key !== undefined && entry.withQuery === undefined && entry.for === undefined) {
|
|
157
|
+
throw new AppCompileError('JA0008',
|
|
158
|
+
`subscription ${i} ('${entry.run}') carries "key" without "withQuery" or "for" — a static subscription has no restart key`,
|
|
159
|
+
`/subs/${i}`);
|
|
160
|
+
}
|
|
161
|
+
const instanceExternals = entry.for !== undefined ? ['item'] : [];
|
|
162
|
+
const forQuery = compileSubQuery(entry, i, 'for', options, []);
|
|
163
|
+
const withQuery = compileSubQuery(entry, i, 'withQuery', options, instanceExternals);
|
|
164
|
+
const keyQuery = compileSubQuery(entry, i, 'key', options, instanceExternals);
|
|
165
|
+
return { run: entry.run, props: entry.with ?? null, when, withQuery, keyQuery, forQuery };
|
|
166
|
+
});
|
|
167
|
+
}
|