@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/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# @jarenjs/app
|
|
2
|
+
|
|
3
|
+
Applications as JSON documents. This package rebuilds [hyperapp](https://github.com/jorgebucaran/hyperapp)'s dispatch loop on the Jaren suite and pushes its philosophy — *everything is data* — the rest of the way: hyperapp made effects and subscriptions data but kept actions and views as JavaScript functions; here **the whole application is one JSON value**:
|
|
4
|
+
|
|
5
|
+
| Slot | Written as | Compiled by |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `state` | a JSON document | — |
|
|
8
|
+
| `view` | a [JSLT stylesheet](../json/docs/JSLT-FORMAT.md) producing [vnodes](../view/docs/VIEW-FORMAT.md) | `@jarenjs/json/jslt` |
|
|
9
|
+
| `actions` | named [query documents](../json/docs/QUERY-FORMAT.md) producing transitions | `@jarenjs/json/query` |
|
|
10
|
+
| transitions | next state, or an RFC 6902 **JSON Patch** | `@jarenjs/json/patch` |
|
|
11
|
+
| `subs` | entries with an EBV `when` query deciding liveness | `@jarenjs/json/query` |
|
|
12
|
+
|
|
13
|
+
Everything compiles **once** at `createApp` time; the running loop only calls specialized closures — the same design contract as every other Jaren engine. JavaScript enters at named, registered boundaries only: effect and subscription handlers, the `compileTypeTest` hook, the `validateState` invariant hook. No `eval`, CSP-safe, and the entire app is serializable: snapshot it, diff it, ship it over the wire, or have a constrained decoder generate it — an LLM cannot emit a syntactically invalid program in this framework.
|
|
14
|
+
|
|
15
|
+
The document contract is [docs/APP-FORMAT.md](docs/APP-FORMAT.md).
|
|
16
|
+
|
|
17
|
+
## A complete app
|
|
18
|
+
|
|
19
|
+
```javascript
|
|
20
|
+
import { createApp } from '@jarenjs/app';
|
|
21
|
+
|
|
22
|
+
const app = createApp({
|
|
23
|
+
"$app": "0.1",
|
|
24
|
+
"state": { "count": 0 },
|
|
25
|
+
|
|
26
|
+
"view": [
|
|
27
|
+
{ "match": "$", "body":
|
|
28
|
+
["main", {},
|
|
29
|
+
["h1", {}, "Count: ", "$.count"],
|
|
30
|
+
["button", { "on": { "click": "inc" } }, "+"],
|
|
31
|
+
["button", { "on": { "click": { "action": "add", "with": 10 } } }, "+10"]] }
|
|
32
|
+
],
|
|
33
|
+
|
|
34
|
+
"actions": {
|
|
35
|
+
"inc": { "patch": [{ "op": "replace", "path": "/count",
|
|
36
|
+
"value": { "$add": ["$.count", 1] } }] },
|
|
37
|
+
"add": { "patch": [{ "op": "replace", "path": "/count",
|
|
38
|
+
"value": { "$add": ["$.count", "$payload"] } }] }
|
|
39
|
+
}
|
|
40
|
+
}, { node: document.getElementById('app') });
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The view is a JSLT stylesheet: rules match state by location (JSONPath) and shape (JSON Schema, via `compileTypeTest`), bodies are query documents producing vnodes, and `$path`/`$root` are in scope — a rule rendering `/todos/3` can embed its own pointer in an event binding, which is why there are no payload-creator functions anywhere.
|
|
44
|
+
|
|
45
|
+
## Actions and transitions
|
|
46
|
+
|
|
47
|
+
An action document is evaluated with `$` bound to the current state, `$event` bound to serializable event data (`{ type, value, checked, key }`) and `$payload` bound to the binding's `with` value. A binding can request more of the event declaratively — `{ "action": "selectRow", "with": { "id": "$.id" }, "event": ["shiftKey", "ctrlKey"] }` adds those members to `$event` from a closed allow-list of serializable fields (modifier keys, pointer coordinates, selection offsets, ...), and the `eventFields` option registers named JS extractors for anything the allow-list can't serialize (APP-FORMAT §3.1/§5.4). It returns a **transition**:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{ "state": "the next state, whole — optional",
|
|
51
|
+
"patch": "an RFC 6902 patch applied copy-on-write — optional",
|
|
52
|
+
"effects": [{ "run": "http", "with": { "url": "/api" } }] }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Returning nothing is a no-op. State updates are immutable and structure-sharing (the patch engine's copy-on-write), which feeds the renderer's `oldVnode === newVnode` fast path.
|
|
56
|
+
|
|
57
|
+
## Effects and subscriptions
|
|
58
|
+
|
|
59
|
+
Side effects stay at the edges, as registered handlers:
|
|
60
|
+
|
|
61
|
+
```javascript
|
|
62
|
+
createApp(doc, {
|
|
63
|
+
node,
|
|
64
|
+
effects: {
|
|
65
|
+
http: (props, dispatch) =>
|
|
66
|
+
fetch(props.url).then((r) => r.json()).then((data) => dispatch(props.done, data)),
|
|
67
|
+
},
|
|
68
|
+
subs: {
|
|
69
|
+
interval: (props, dispatch) => {
|
|
70
|
+
const id = setInterval(() => dispatch(props.tick), props.ms);
|
|
71
|
+
return () => clearInterval(id); // cleanup
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Subscription entries in the document carry a `when` query; after every state change the loop starts and stops handlers to match (`{ "run": "interval", "with": { "ms": 1000, "tick": "tick" }, "when": "$.running" }`). A broken `when` fails **closed** — a broken rule must never keep side effects alive — and is reported through `onError`.
|
|
78
|
+
|
|
79
|
+
## Async tasks
|
|
80
|
+
|
|
81
|
+
Async work follows a documented convention — **correctness lives in state, cancellation lives in the host** — with one shipped helper packaging the host half:
|
|
82
|
+
|
|
83
|
+
```javascript
|
|
84
|
+
import { createTaskEffect } from '@jarenjs/app';
|
|
85
|
+
|
|
86
|
+
// state: "tasks": { "list": { "id": 0, "status": "idle", "error": null } }
|
|
87
|
+
// start: patch increments /tasks/list/id AND the effect's with.id
|
|
88
|
+
// carries the same increment expression (evaluated pre-transition!)
|
|
89
|
+
// finish: the completion action guards { "$eq": ["$payload.id", "$.tasks.list.id"] }
|
|
90
|
+
// and yields the empty sequence for anything stale
|
|
91
|
+
effects: {
|
|
92
|
+
http: createTaskEffect((props, signal) =>
|
|
93
|
+
fetch(props.url, { signal }).then((r) => r.json())),
|
|
94
|
+
}
|
|
95
|
+
```
|
|
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).
|
|
98
|
+
|
|
99
|
+
## One FIFO queue, observable transactions
|
|
100
|
+
|
|
101
|
+
Every dispatch — from the DOM, an effect, a listener, a subscription or a widget — is one **transaction** on one FIFO queue; nested dispatches queue, never interleave, so every listener observes every transaction in the same order with the state that transaction produced ([APP-FORMAT §8](docs/APP-FORMAT.md)). Boot is a transaction too: a failure in renderer construction, the initial-state check, a starting subscription, the first frame or the queued boot work rolls back everything acquired (effect handlers disposed, container emptied) and throws `JA0007`. `app.stop()` halts the loop one-way (no resume); `app.destroy()` is the terminal teardown — subscriptions cleaned, effect handlers disposed, widgets unmounted exactly once, the container emptied.
|
|
102
|
+
|
|
103
|
+
`app.observe(fn)` streams one bounded JSON record per transaction (`seq`, `action`, `source`, `status`, `changedPaths`, `scheduledEffects`, `durationMs`, `errorCode` — payloads only with the `capturePayloads` opt-in), and `createTransactionLog({ limit, redact })` packages the ring buffer with a redaction hook for support exports. `createFocusEffect({ container })` bridges focus, text selection and measurement through post-render `data-ref` intents, so accessible dialogs restore focus without a DOM node ever entering state (§8.4).
|
|
104
|
+
|
|
105
|
+
## Widgets — imperative islands, declarative everything else
|
|
106
|
+
|
|
107
|
+
An irreducibly imperative island — a virtualized grid, a canvas, a map — lives behind a **registered widget** ([VIEW-FORMAT §7](../view/docs/VIEW-FORMAT.md)); the app document stays JSON and the app option only names the boundary, like `effects` and `subs`:
|
|
108
|
+
|
|
109
|
+
```javascript
|
|
110
|
+
createApp({
|
|
111
|
+
state: { grid: { rows: hugeArray, scrollTop: 0 }, selected: null },
|
|
112
|
+
view: [
|
|
113
|
+
{ "match": "$", "body": ["main", {}, { "$apply": "$.grid" }] },
|
|
114
|
+
{ "match": "$.grid", "body":
|
|
115
|
+
["jaren-widget", { "name": "virtual-list", "key": "list", "props": {
|
|
116
|
+
"rows": "$.rows", "scrollTop": "$.scrollTop",
|
|
117
|
+
"binding": { "action": "select", "with": {} } } }] },
|
|
118
|
+
],
|
|
119
|
+
actions: { select: { patch: [{ op: 'add', path: '/selected', value: '$payload.id' }] } },
|
|
120
|
+
}, { node, widgets: { 'virtual-list': virtualListWidget } });
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Jaren owns state and orchestration; the widget owns its DOM. Its `props` come from the view stylesheet and are compared **by reference** — the JSLT memo means a transition that doesn't touch the widget's state slice never calls into the widget at all. The widget dispatches by composing runtime data into the binding its props carry and handing it to `emit`, which flows through the ordinary binding path — `with` payloads and `event` extraction included — and it is unmounted deterministically when it leaves the tree.
|
|
124
|
+
|
|
125
|
+
### A ready-made splitter, and a document store
|
|
126
|
+
|
|
127
|
+
Two IDE-shaped primitives ship ready to bind, so a two-pane surface (the studio, the play playground) doesn't re-implement them:
|
|
128
|
+
|
|
129
|
+
- **`createSplitterWidget({ action, grid, rail, cssVar, min, max, step })`** — a drag handle over a pane boundary. It drives a CSS ratio variable *live* during a drag (no per-move dispatch — that would flood the transaction log and undo) and commits the ratio through `action` on pointer-up only, plus keyboard resize as an ARIA separator. Register it like any widget; parameterize the grid/rail selectors, the CSS variable and the commit action so each surface binds its own.
|
|
130
|
+
- **`createDocStore({ storage, key })`** — a keyed `save`/`load`/`remove`/`names`/`all` CRUD over an injected `storage` (`localStorage` in the browser, an in-memory object in tests), so the package never touches `localStorage` itself. Paired with **`encodeShare(snapshot)`** / **`decodeShare(token)`**, a Unicode-safe base64url share-link codec (a corrupt token decodes to `null`, never a throw), it is the new/save/load/delete/share pattern behind the studio and play surfaces.
|
|
131
|
+
|
|
132
|
+
## Invariants the model can't cheat
|
|
133
|
+
|
|
134
|
+
```javascript
|
|
135
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
136
|
+
import { createTypeTestCompiler } from '@jarenjs/validate/query';
|
|
137
|
+
|
|
138
|
+
const validate = new JarenValidator().compile(stateSchema); // may carry $query assertions
|
|
139
|
+
|
|
140
|
+
createApp(doc, {
|
|
141
|
+
node,
|
|
142
|
+
compileTypeTest: createTypeTestCompiler(), // enables schema matches & $valid/$assert/$as
|
|
143
|
+
validateState: (state) => validate(state), // every transition checked; rejected = not applied
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`validateState` runs against every candidate next state; a rejection blocks the transition (fail closed) and surfaces as a `JA2005` error with the validator's structured errors in `detail`. The app package itself never imports the validator — the same boundary discipline as `@jarenjs/forms`.
|
|
148
|
+
|
|
149
|
+
## The standard forms stylesheet
|
|
150
|
+
|
|
151
|
+
The marquee integration: render any [`@jarenjs/forms`](../forms) model with **zero hand-written render code**. `createFormView()` returns a plain-JSON JSLT rule set that dispatches over a `buildFormViewModel` tree by *shape* (JSONPath filter selectors on each node's `control`), and `createFormActions()` returns the matching action documents that write keystrokes back into the state — choosing the correct RFC 6902 op per node (`replace` for array elements, where `add` would insert; `add` for object members, where it means set-or-replace).
|
|
152
|
+
|
|
153
|
+
```javascript
|
|
154
|
+
import { createApp, createFormView, createFormActions, formEventFields } from '@jarenjs/app';
|
|
155
|
+
import { buildFormModel, compileFormRules, createInitialData, buildFormViewModel } from '@jarenjs/forms';
|
|
156
|
+
|
|
157
|
+
const model = buildFormModel(schema);
|
|
158
|
+
const rules = compileFormRules(model);
|
|
159
|
+
|
|
160
|
+
const app = createApp({
|
|
161
|
+
state: { data: createInitialData(model) },
|
|
162
|
+
view: [
|
|
163
|
+
...createFormView(), // the shipped rule set
|
|
164
|
+
{ match: '$', body: ['main', {}, { $apply: '$.form' }] },
|
|
165
|
+
],
|
|
166
|
+
actions: createFormActions({ dataPointer: '/data' }),
|
|
167
|
+
}, {
|
|
168
|
+
node: document.getElementById('app'),
|
|
169
|
+
eventFields: { ...formEventFields() }, // decode the JSON-carrying controls
|
|
170
|
+
viewModel: (state) => ({ // the derivation boundary
|
|
171
|
+
form: buildFormViewModel(model, state.data, { rules, validateFields: true }),
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Schema in, live form out: text/email/number/date/color inputs, textareas, checkboxes, selects with precomputed options, nested object fieldsets, arrays with add/remove buttons, inline errors, and `x-form` visibility/enablement/computed reacting per keystroke. The `viewModel` option is the general **derivation boundary**: it maps state to the view stylesheet's input before every render, so JS-computed derivations enter the render path without ever entering the state. A DOM control's value is a string, and two controls carry something else: a select over a non-string enum, and the `json` editor over a structured value. Both round-trip through JSON text and decode it in `formEventFields()`, the format's one sanctioned place for host JavaScript at the DOM boundary (APP-FORMAT §5.4) — **register it or those two controls write nothing**. Remaining 0.1 limits (documented in `src/forms.js`): a cleared number input writes `null`, and arrays need to exist in the data (give them `default: []` in the schema).
|
|
177
|
+
|
|
178
|
+
## Headless and server-side
|
|
179
|
+
|
|
180
|
+
Without a `node`, the app runs headless: `getVnode()` returns the current view output for any renderer, and SSR is one composition:
|
|
181
|
+
|
|
182
|
+
```javascript
|
|
183
|
+
import { renderToString } from '@jarenjs/view';
|
|
184
|
+
renderToString(createApp(doc).getVnode());
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## API
|
|
188
|
+
|
|
189
|
+
`createApp(appDoc, options)` → `{ dispatch(name, payload?), getState(), getVnode(), render(), subscribe(listener), observe(observer), stop(), destroy() }`
|
|
190
|
+
|
|
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
|
+
|
|
193
|
+
Options: `node`, `document`, `effects`, `subs`, `eventFields` (named `$event` field extractors), `widgets` (registered widget definitions, forwarded to the renderer), `compileTypeTest`, `validateState`, `viewModel`, `onError` (default rethrows), `schedule` (render batching; default microtask — pass `(f) => f()` for synchronous tests). Compile failures throw `AppCompileError` (`JA0xxx`, with a `docPath` into the app document); runtime failures route `AppRuntimeError` (`JA2xxx`) through `onError`. The full code table is in [APP-FORMAT.md](docs/APP-FORMAT.md) §10.
|
|
194
|
+
|
|
195
|
+
## Development
|
|
196
|
+
|
|
197
|
+
Unit tests live in `test/app/` at the repository root (`npm run test:app`). See [ROADMAP](../../docs/ROADMAP.md) for what's next: dirty-path-pruned re-rendering and time-travel tooling over the action log.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Compiling the transition vocabulary of an app document: named
|
|
3
|
+
* action documents and subscription entries. Everything here runs once,
|
|
4
|
+
* at `createApp` time — the loop only ever calls compiled closures.
|
|
5
|
+
*
|
|
6
|
+
* An **action document** is a Jaren JSON Query document (QUERY-FORMAT.md)
|
|
7
|
+
* evaluated with `$` bound to the current state and two externals:
|
|
8
|
+
*
|
|
9
|
+
* - `$event` — the serializable event data (`{ type, value, checked,
|
|
10
|
+
* key }`) when the dispatch came from the DOM, else `null`
|
|
11
|
+
* - `$payload` — the binding's `with` value, else `null`
|
|
12
|
+
*
|
|
13
|
+
* It returns a **transition object** (or nothing for a no-op):
|
|
14
|
+
*
|
|
15
|
+
* - `state` — the next state, whole
|
|
16
|
+
* - `patch` — an RFC 6902 JSON Patch applied to the state (after
|
|
17
|
+
* `state`, when both are present)
|
|
18
|
+
* - `effects` — `[{ "run": name, "with"?: props }]` handed to the
|
|
19
|
+
* registered effect handlers
|
|
20
|
+
*/
|
|
21
|
+
export type ActionCompileOptions = {
|
|
22
|
+
/**
|
|
23
|
+
* Enables `$valid`/`$assert`/`$as` schema operators inside action
|
|
24
|
+
* documents; typically `createTypeTestCompiler()` from
|
|
25
|
+
* `@jarenjs/validate/query`.
|
|
26
|
+
*/
|
|
27
|
+
compileTypeTest?: (schema: any, docPath: string) => ((value: any) => boolean);
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The compile-time options shared by every embedded query document.
|
|
31
|
+
* @typedef {Object} ActionCompileOptions
|
|
32
|
+
* @property {(schema: any, docPath: string) => ((value: any) => boolean)} [compileTypeTest]
|
|
33
|
+
* Enables `$valid`/`$assert`/`$as` schema operators inside action
|
|
34
|
+
* documents; typically `createTypeTestCompiler()` from
|
|
35
|
+
* `@jarenjs/validate/query`.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* Compile the `actions` member of an app document.
|
|
39
|
+
* @param {any} actions
|
|
40
|
+
* @param {ActionCompileOptions} options
|
|
41
|
+
* @returns {Map<string, any>} action name → compiled query
|
|
42
|
+
*/
|
|
43
|
+
export declare function compileActions(actions: any, options: ActionCompileOptions): Map<string, any>;
|
|
44
|
+
export type CompiledSub = {
|
|
45
|
+
/**
|
|
46
|
+
* - The registered handler name.
|
|
47
|
+
*/
|
|
48
|
+
run: string;
|
|
49
|
+
/**
|
|
50
|
+
* - The entry's `with` value (`null` when absent).
|
|
51
|
+
*/
|
|
52
|
+
props: any;
|
|
53
|
+
/**
|
|
54
|
+
* - Compiled liveness query, or `null` (always live).
|
|
55
|
+
*/
|
|
56
|
+
when: any;
|
|
57
|
+
/**
|
|
58
|
+
* - Compiled props query evaluated against the
|
|
59
|
+
* state (with `$item` bound per instance under `for`), or `null`.
|
|
60
|
+
*/
|
|
61
|
+
withQuery: any;
|
|
62
|
+
/**
|
|
63
|
+
* - Compiled restart-key query, or `null` (the
|
|
64
|
+
* key derives from the resolved props by value).
|
|
65
|
+
*/
|
|
66
|
+
keyQuery: any;
|
|
67
|
+
/**
|
|
68
|
+
* - Compiled fan-out query yielding the item
|
|
69
|
+
* set, or `null` (a single-instance subscription).
|
|
70
|
+
*/
|
|
71
|
+
forQuery: any;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Compile the `subs` member of an app document:
|
|
75
|
+
* `[{ "run": name, "with"?: props, "when"?: <EBV query>,
|
|
76
|
+
* "withQuery"?: query, "key"?: query, "for"?: query }]`.
|
|
77
|
+
*
|
|
78
|
+
* `with` is verbatim data; `withQuery` derives the props from the state
|
|
79
|
+
* and makes the subscription DYNAMIC — it restarts when its resolved
|
|
80
|
+
* key changes (`key` overrides the derived-from-props default). `for`
|
|
81
|
+
* fans the declaration out to one instance per item of its result. The
|
|
82
|
+
* combinations that would make one entry ambiguous are JA0008: `with`
|
|
83
|
+
* beside `withQuery`, `with` beside `for`, and `key` without either
|
|
84
|
+
* `withQuery` or `for`.
|
|
85
|
+
* @param {any} subs
|
|
86
|
+
* @param {ActionCompileOptions} options
|
|
87
|
+
* @returns {CompiledSub[]}
|
|
88
|
+
*/
|
|
89
|
+
export declare function compileSubs(subs: any, options: ActionCompileOptions): CompiledSub[];
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The Jaren application loop.
|
|
3
|
+
*
|
|
4
|
+
* `createApp` takes an **app document** — one JSON value holding the
|
|
5
|
+
* initial state, a JSLT view stylesheet, named action documents and
|
|
6
|
+
* subscription entries — compiles every embedded document once, and runs
|
|
7
|
+
* a serialized dispatch loop over the compiled closures:
|
|
8
|
+
*
|
|
9
|
+
* DOM event → binding → action document → transition → next state
|
|
10
|
+
* → effects → listeners → subscriptions refresh → batched re-render
|
|
11
|
+
*
|
|
12
|
+
* JavaScript enters only at named, registered boundaries: effect and
|
|
13
|
+
* subscription handlers, the `compileTypeTest` hook, and the optional
|
|
14
|
+
* `validateState` invariant hook. Everything between the boundaries is
|
|
15
|
+
* data. See docs/APP-FORMAT.md for the document contract.
|
|
16
|
+
*
|
|
17
|
+
* **Transaction model (APP-FORMAT §8).** Every dispatch is one
|
|
18
|
+
* transaction on one FIFO queue. Only the queue drain evaluates and
|
|
19
|
+
* applies transitions; a dispatch from an effect, listener, observer,
|
|
20
|
+
* subscription handler, widget or lifecycle hook queues behind the
|
|
21
|
+
* current transaction and never nests. Within a transaction the order
|
|
22
|
+
* is: state commit → effect invocation → listener notification →
|
|
23
|
+
* subscription reconciliation → render scheduling → observer
|
|
24
|
+
* notification; all of it completes before the next transaction begins.
|
|
25
|
+
* Every listener therefore observes every transaction in the same
|
|
26
|
+
* order, and each notification carries the state produced by exactly
|
|
27
|
+
* that transaction. Native event data is reduced to JSON synchronously
|
|
28
|
+
* at dispatch time, before queuing. A listener, observer or cleanup
|
|
29
|
+
* error is isolated: it is reported through `onError`, and whatever the
|
|
30
|
+
* sink throws is re-thrown only after the drain has fully completed —
|
|
31
|
+
* the queue always drains, cleanups are never skipped.
|
|
32
|
+
*/
|
|
33
|
+
export type AppOptions = {
|
|
34
|
+
/**
|
|
35
|
+
* - DOM element to mount into; omit for a headless
|
|
36
|
+
* app (drive it via `getVnode`/`subscribe`).
|
|
37
|
+
*/
|
|
38
|
+
node?: any;
|
|
39
|
+
/**
|
|
40
|
+
* - The DOM document (defaults to
|
|
41
|
+
* `node.ownerDocument`).
|
|
42
|
+
*/
|
|
43
|
+
document?: any;
|
|
44
|
+
/**
|
|
45
|
+
* Effect handlers by name. A handler function may carry an optional
|
|
46
|
+
* `dispose()` member, called exactly once by `app.destroy()` (a
|
|
47
|
+
* handler registered under several names is disposed once).
|
|
48
|
+
*/
|
|
49
|
+
effects?: Record<string, (props: any, dispatch: Dispatch) => void>;
|
|
50
|
+
/**
|
|
51
|
+
* Subscription handlers by name; may return a cleanup function.
|
|
52
|
+
*/
|
|
53
|
+
subs?: Record<string, (props: any, dispatch: Dispatch) => (() => void) | void>;
|
|
54
|
+
/**
|
|
55
|
+
* Named event-field extractors: when a binding requests a field by
|
|
56
|
+
* name, an extractor registered here wins over the built-in
|
|
57
|
+
* allow-list. An extractor receives the native event and MUST return
|
|
58
|
+
* a JSON value (`$event` stays serializable end to end).
|
|
59
|
+
*/
|
|
60
|
+
eventFields?: Record<string, (nativeEvent: any) => any>;
|
|
61
|
+
/**
|
|
62
|
+
* - Registered widget
|
|
63
|
+
* definitions by name for `jaren-widget` vnodes (VIEW-FORMAT §7),
|
|
64
|
+
* forwarded to the renderer — the mechanism lives in `@jarenjs/view`;
|
|
65
|
+
* the app only names the boundary, like effects and subs.
|
|
66
|
+
*/
|
|
67
|
+
widgets?: Record<string, any>;
|
|
68
|
+
/**
|
|
69
|
+
* Enables JSON Schema operators (`$valid`/`$assert`/`$as`) and schema
|
|
70
|
+
* matches inside the view and the action documents.
|
|
71
|
+
*/
|
|
72
|
+
compileTypeTest?: (schema: any, docPath: string) => ((value: any) => boolean);
|
|
73
|
+
/**
|
|
74
|
+
* Invariant hook, called with every candidate next state plus a
|
|
75
|
+
* context carrying the previous state, the acting action name, its
|
|
76
|
+
* payload and the transition's changed paths (`null` = unknown, the
|
|
77
|
+
* whole state must be treated as changed — selective validation on
|
|
78
|
+
* `changes` is only sound when the hook falls back to a full check
|
|
79
|
+
* for `null`). A rejection (`false` or `{ valid: false }`) blocks the
|
|
80
|
+
* transition (fail closed) and reports `JA2005` through `onError`.
|
|
81
|
+
* The hook is host code and may itself fail: a throwing validator is
|
|
82
|
+
* isolated as `JA2015` (the transaction fails, the original cause is
|
|
83
|
+
* preserved, and the queue keeps draining — parked errors from the
|
|
84
|
+
* default rethrowing sink surface only after the drain). The hook
|
|
85
|
+
* also runs once at boot against the initial state (see
|
|
86
|
+
* {@link ValidateContext}).
|
|
87
|
+
*/
|
|
88
|
+
validateState?: (state: any, context: ValidateContext) => boolean | {
|
|
89
|
+
valid: boolean;
|
|
90
|
+
errors?: any;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* - The derivation boundary:
|
|
94
|
+
* maps the state to the view stylesheet's input document before every
|
|
95
|
+
* render (default identity). This is where JS-computed derivations —
|
|
96
|
+
* `buildFormViewModel` from `@jarenjs/forms`, aggregations, joins —
|
|
97
|
+
* enter the render path without ever entering the state.
|
|
98
|
+
*/
|
|
99
|
+
viewModel?: (state: any) => any;
|
|
100
|
+
/**
|
|
101
|
+
* - Runtime error sink;
|
|
102
|
+
* default rethrows. Errors reported from isolated sites (listeners,
|
|
103
|
+
* observers, cleanups, unknown event fields) never break the
|
|
104
|
+
* transaction queue: whatever the sink throws surfaces to the outer
|
|
105
|
+
* dispatch caller only after the drain completes.
|
|
106
|
+
*/
|
|
107
|
+
onError?: (error: Error) => void;
|
|
108
|
+
/**
|
|
109
|
+
* - Render scheduler;
|
|
110
|
+
* default batches on a microtask. Pass `(f) => f()` for synchronous
|
|
111
|
+
* rendering (tests, SSR pipelines).
|
|
112
|
+
*/
|
|
113
|
+
schedule?: (flush: () => void) => void;
|
|
114
|
+
/**
|
|
115
|
+
* - Called exactly once per
|
|
116
|
+
* settled, NONTERMINAL committed frame: after the DOM patch and
|
|
117
|
+
* widget mounts complete, and — when a widget hook error was parked
|
|
118
|
+
* during the frame — BEFORE that error is delivered to `onError`
|
|
119
|
+
* (the committed frame is real; its callback is never starved by
|
|
120
|
+
* error delivery). Never called for a pass that ended in terminal
|
|
121
|
+
* teardown, and never on headless apps. The post-render
|
|
122
|
+
* focus/measurement queue (`createFocusEffect`) plugs in here.
|
|
123
|
+
*/
|
|
124
|
+
afterRender?: () => void;
|
|
125
|
+
/**
|
|
126
|
+
* - The dispatch-loop guard (default
|
|
127
|
+
* 1000): the maximum number of transactions one drain may process
|
|
128
|
+
* before the queue is abandoned with `JA2010` — an accidental
|
|
129
|
+
* action→effect→action loop diagnoses instead of hanging.
|
|
130
|
+
*/
|
|
131
|
+
maxTurns?: number;
|
|
132
|
+
/**
|
|
133
|
+
* - The subscription fan-out
|
|
134
|
+
* bound (default 256): a `for` declaration resolving more instances
|
|
135
|
+
* than this reports `JA2017` and keeps its previous instance set —
|
|
136
|
+
* an unbounded fan-out driven by state is a resource bug waiting for
|
|
137
|
+
* a bad query.
|
|
138
|
+
*/
|
|
139
|
+
maxSubInstances?: number;
|
|
140
|
+
/**
|
|
141
|
+
* - Include `payload` and `event`
|
|
142
|
+
* values in transaction records handed to observers (default false —
|
|
143
|
+
* diagnostics must not leak data by default).
|
|
144
|
+
*/
|
|
145
|
+
capturePayloads?: boolean;
|
|
146
|
+
};
|
|
147
|
+
export type ValidateContext = {
|
|
148
|
+
/**
|
|
149
|
+
* - The state the transition started from
|
|
150
|
+
* (`null` for the boot-time initial-state check).
|
|
151
|
+
*/
|
|
152
|
+
previous: any;
|
|
153
|
+
/**
|
|
154
|
+
* - The acting action name (`null`
|
|
155
|
+
* for the boot-time initial-state check).
|
|
156
|
+
*/
|
|
157
|
+
action: string | null;
|
|
158
|
+
/**
|
|
159
|
+
* - The dispatch payload (`null` when absent).
|
|
160
|
+
*/
|
|
161
|
+
payload: any;
|
|
162
|
+
/**
|
|
163
|
+
* - Changed JSON Pointers when the
|
|
164
|
+
* transition was patch-only, else `null` (= unknown, validate fully).
|
|
165
|
+
*/
|
|
166
|
+
changes: string[] | null;
|
|
167
|
+
};
|
|
168
|
+
export type TransactionRecord = {
|
|
169
|
+
/**
|
|
170
|
+
* - Monotonic transaction sequence number.
|
|
171
|
+
*/
|
|
172
|
+
seq: number;
|
|
173
|
+
/**
|
|
174
|
+
* - The dispatched action name.
|
|
175
|
+
*/
|
|
176
|
+
action: string;
|
|
177
|
+
/**
|
|
178
|
+
* - What queued it: `'dispatch'` (external),
|
|
179
|
+
* `'binding'` (DOM/widget), `'effect'`, `'subscription'`, or
|
|
180
|
+
* `'setState'` (a whole-state replacement from outside the loop).
|
|
181
|
+
*/
|
|
182
|
+
source: string;
|
|
183
|
+
status: 'applied' | 'noop' | 'rejected' | 'failed';
|
|
184
|
+
/**
|
|
185
|
+
* - Changed JSON Pointers, or
|
|
186
|
+
* `null` when the whole state was replaced (unknown = everything).
|
|
187
|
+
*/
|
|
188
|
+
changedPaths: string[] | null;
|
|
189
|
+
/**
|
|
190
|
+
* - Names of effects invoked.
|
|
191
|
+
*/
|
|
192
|
+
scheduledEffects: string[];
|
|
193
|
+
durationMs: number;
|
|
194
|
+
/**
|
|
195
|
+
* - The `JA2xxx` code when the
|
|
196
|
+
* transaction failed or was rejected.
|
|
197
|
+
*/
|
|
198
|
+
errorCode: string | null;
|
|
199
|
+
payload?: any;
|
|
200
|
+
event?: any;
|
|
201
|
+
};
|
|
202
|
+
export type Dispatch = (name: string, payload?: any, domEvent?: any, eventFields?: string[] | null) => void;
|
|
203
|
+
/**
|
|
204
|
+
* Compile an app document and start the loop.
|
|
205
|
+
*
|
|
206
|
+
* Boot is a transaction: compiling the documents, creating the
|
|
207
|
+
* renderer, validating the initial state, starting the initial
|
|
208
|
+
* subscriptions, painting the first frame and draining the dispatches
|
|
209
|
+
* queued by starting handlers either all succeed, or every
|
|
210
|
+
* already-acquired resource (subscriptions, effect handlers, the
|
|
211
|
+
* renderer — the container ends empty) is disposed and `createApp`
|
|
212
|
+
* throws `JA0007` (an `AppCompileError` carrying the original failure
|
|
213
|
+
* as `cause`). `onError` observes individual boot-time failures first —
|
|
214
|
+
* a sink that swallows a subscription-start failure, the initial-state
|
|
215
|
+
* check or an error inside queued boot work recovers it and boots the
|
|
216
|
+
* rest; renderer-construction and first-frame failures are always
|
|
217
|
+
* fatal; the default rethrowing sink aborts boot on any of them. After
|
|
218
|
+
* a successful boot the returned app never throws from `createApp`
|
|
219
|
+
* paths again.
|
|
220
|
+
*
|
|
221
|
+
* @param {any} appDoc
|
|
222
|
+
* @param {AppOptions} [options]
|
|
223
|
+
*/
|
|
224
|
+
export declare function createApp(appDoc: any, options?: AppOptions): {
|
|
225
|
+
dispatch: (name: string, payload?: any, domEvent?: any, eventFields?: string[] | null) => void;
|
|
226
|
+
/** The current state (treat as immutable). */
|
|
227
|
+
getState: () => any;
|
|
228
|
+
/**
|
|
229
|
+
* Replace the whole state from OUTSIDE the action loop and re-render.
|
|
230
|
+
* Unlike `dispatch`, this runs no reducer and no effects — it is the
|
|
231
|
+
* host-driven override for external state sync: SSR hydration, or a
|
|
232
|
+
* studio hot-swapping an edited `state` block into a running nested
|
|
233
|
+
* app without a reboot (the diff re-render keeps the DOM, so focus,
|
|
234
|
+
* scroll and uncontrolled inputs survive). Listeners are notified with
|
|
235
|
+
* `null` changed-paths (treat everything as changed); `when`-gated
|
|
236
|
+
* subscriptions refresh; a render is scheduled. A no-op when the state
|
|
237
|
+
* is reference-identical or the loop has been stopped.
|
|
238
|
+
*
|
|
239
|
+
* It is a TRANSACTION, with every guarantee a dispatch has: it takes
|
|
240
|
+
* its turn in the FIFO queue, `validateState` decides before the
|
|
241
|
+
* commit, listeners all observe the same state, the turn guard counts
|
|
242
|
+
* it, one record reaches the observers, and a sink failure settles at
|
|
243
|
+
* this caller. Replacing state directly had none of those — a listener
|
|
244
|
+
* that dispatched saw the two transactions interleave and never
|
|
245
|
+
* observed its own committed state, an invalid replacement committed
|
|
246
|
+
* unvalidated, and the resulting `JA2011` emerged from the next
|
|
247
|
+
* unrelated dispatch.
|
|
248
|
+
* @param {any} next - the replacement state
|
|
249
|
+
*/
|
|
250
|
+
setState(next: any): void;
|
|
251
|
+
/** The current view output — for SSR or custom renderers. */
|
|
252
|
+
getVnode: () => any;
|
|
253
|
+
render: () => void;
|
|
254
|
+
/**
|
|
255
|
+
* Observe state changes. The listener receives the new state and the
|
|
256
|
+
* transition's changed paths: an array of JSON Pointers when the
|
|
257
|
+
* transition was patch-only (see the patch engine's `changes` option
|
|
258
|
+
* for the invalidation-sound semantics), or `null` when the whole
|
|
259
|
+
* state was replaced — treat everything as changed. Listeners run
|
|
260
|
+
* inside the transaction, in registration order, all observing the
|
|
261
|
+
* same state/changes pair; a throwing listener is isolated (JA2011).
|
|
262
|
+
* @param {(state: any, changes: string[] | null) => void} listener
|
|
263
|
+
* @returns {() => void} unsubscribe
|
|
264
|
+
*/
|
|
265
|
+
subscribe(listener: (state: any, changes: string[] | null) => void): () => void;
|
|
266
|
+
/**
|
|
267
|
+
* Observe completed transactions (APP-FORMAT §8.3). The observer
|
|
268
|
+
* receives one bounded JSON metadata record per transaction, after
|
|
269
|
+
* the transaction fully settled (state, effects, listeners,
|
|
270
|
+
* subscriptions, render scheduling). Payload/event values are
|
|
271
|
+
* included only when the app was created with `capturePayloads`.
|
|
272
|
+
* A throwing observer is isolated and never corrupts the queue.
|
|
273
|
+
* @param {(tx: TransactionRecord) => void} observer
|
|
274
|
+
* @returns {() => void} unsubscribe
|
|
275
|
+
*/
|
|
276
|
+
observe(observer: (tx: TransactionRecord) => void): () => void;
|
|
277
|
+
/**
|
|
278
|
+
* Stop the loop — one-way and nonterminal, not a resumable pause:
|
|
279
|
+
* live subscriptions are cleaned up (isolated), listeners are
|
|
280
|
+
* cleared and further dispatches are ignored, permanently. The
|
|
281
|
+
* renderer and effect handlers stay untouched — `destroy()` is the
|
|
282
|
+
* terminal teardown that owns them.
|
|
283
|
+
*/
|
|
284
|
+
stop(): void;
|
|
285
|
+
/**
|
|
286
|
+
* Terminal teardown: `stop()` plus observer removal, effect-handler
|
|
287
|
+
* `dispose()` (each handler identity once), and renderer
|
|
288
|
+
* destruction (widgets unmount exactly once, the container is left
|
|
289
|
+
* empty). Idempotent; scheduled render flushes become exact no-ops;
|
|
290
|
+
* every cleanup error is isolated so siblings always run.
|
|
291
|
+
*/
|
|
292
|
+
destroy(): void;
|
|
293
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A bounded transaction log over `app.observe` (APP-FORMAT §8.3).
|
|
3
|
+
*
|
|
4
|
+
* The log lives OUTSIDE application state by design: diagnostics are
|
|
5
|
+
* host memory, never data. Records are the observer's bounded JSON
|
|
6
|
+
* metadata; payload capture stays whatever the app was created with
|
|
7
|
+
* (`capturePayloads`), so the log adds no leak surface of its own. A
|
|
8
|
+
* redaction hook lets a host scrub or drop records before they are
|
|
9
|
+
* retained at all.
|
|
10
|
+
*/
|
|
11
|
+
export type TransactionLogOptions = {
|
|
12
|
+
/**
|
|
13
|
+
* - Maximum retained records (default 200);
|
|
14
|
+
* older records fall off the front.
|
|
15
|
+
*/
|
|
16
|
+
limit?: number;
|
|
17
|
+
/**
|
|
18
|
+
* - Applied to every record
|
|
19
|
+
* before retention; return the (possibly rewritten) record, or
|
|
20
|
+
* `null`/`undefined` to drop it entirely. Secrets and personal data
|
|
21
|
+
* are the host's responsibility — this is the hook to enforce it.
|
|
22
|
+
*/
|
|
23
|
+
redact?: (record: any) => any;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} TransactionLogOptions
|
|
27
|
+
* @property {number} [limit] - Maximum retained records (default 200);
|
|
28
|
+
* older records fall off the front.
|
|
29
|
+
* @property {(record: any) => any} [redact] - Applied to every record
|
|
30
|
+
* before retention; return the (possibly rewritten) record, or
|
|
31
|
+
* `null`/`undefined` to drop it entirely. Secrets and personal data
|
|
32
|
+
* are the host's responsibility — this is the hook to enforce it.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Create a bounded transaction log. Wire it up with
|
|
36
|
+
* `app.observe(log.observer)`; read it back with `log.entries()`;
|
|
37
|
+
* export it as versioned JSON with `log.export()`.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* const log = createTransactionLog({ limit: 100 });
|
|
41
|
+
* const stop = app.observe(log.observer);
|
|
42
|
+
* // ... later, in a support bundle:
|
|
43
|
+
* const dump = log.export(); // { version: 1, entries: [...] }
|
|
44
|
+
*
|
|
45
|
+
* @param {TransactionLogOptions} [options]
|
|
46
|
+
*/
|
|
47
|
+
export declare function createTransactionLog(options?: TransactionLogOptions): {
|
|
48
|
+
/**
|
|
49
|
+
* The observer to register with `app.observe`.
|
|
50
|
+
* @param {any} record
|
|
51
|
+
*/
|
|
52
|
+
observer(record: any): void;
|
|
53
|
+
/** The retained records, oldest first (a fresh array each call). */
|
|
54
|
+
entries(): any[];
|
|
55
|
+
/** Drop every retained record. */
|
|
56
|
+
clear(): void;
|
|
57
|
+
/**
|
|
58
|
+
* A versioned export envelope for support bundles. The version
|
|
59
|
+
* covers the envelope shape; record fields follow the observer
|
|
60
|
+
* contract of the app that produced them.
|
|
61
|
+
* @returns {{ version: 1, entries: any[] }}
|
|
62
|
+
*/
|
|
63
|
+
export(): {
|
|
64
|
+
version: 1;
|
|
65
|
+
entries: any[];
|
|
66
|
+
};
|
|
67
|
+
};
|