@jarenjs/linq 0.49.2 → 0.66.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/ARCHITECTURE.md +227 -0
- package/README.md +650 -17
- package/docs/APP-PEN.md +1143 -0
- package/docs/CONTRACT-PEN.md +1221 -0
- package/docs/DB-CLIENT.md +882 -0
- package/docs/FLOW-PEN.md +1033 -0
- package/docs/FORMS-PEN.md +940 -0
- package/docs/JSLT-PEN.md +955 -0
- package/docs/LINQ-FORMAT.md +778 -383
- package/docs/MIGRATION-PEN.md +781 -0
- package/docs/MODEL-PEN.md +1092 -0
- package/docs/QUERY-PEN.md +1724 -0
- package/docs/SCHEMA-PEN.md +1218 -0
- package/package.json +57 -4
- package/src/app/action.js +251 -0
- package/src/app/capture.js +63 -0
- package/src/app/define.js +255 -0
- package/src/app/index.js +20 -0
- package/src/app/patch.js +277 -0
- package/src/app/sub.js +106 -0
- package/src/async.js +377 -75
- package/src/capture-root.js +82 -0
- package/src/concurrency.js +48 -11
- package/src/contract/define.js +282 -0
- package/src/contract/http.js +247 -0
- package/src/contract/index.js +23 -0
- package/src/contract/operation.js +338 -0
- package/src/db/handle.js +89 -0
- package/src/db/include.js +351 -0
- package/src/db/index.js +24 -0
- package/src/db/ledger.js +195 -0
- package/src/db/live.js +43 -0
- package/src/db/membership.js +37 -0
- package/src/db/open.js +130 -0
- package/src/document.js +143 -13
- package/src/effect.js +65 -0
- package/src/errors.js +78 -6
- package/src/expression.js +463 -36
- package/src/federate.js +531 -0
- package/src/flow/capture.js +33 -0
- package/src/flow/dag.js +316 -0
- package/src/flow/fsm.js +323 -0
- package/src/flow/index.js +22 -0
- package/src/forms/index.js +43 -0
- package/src/forms/rules.js +170 -0
- package/src/forms/submit.js +177 -0
- package/src/index.js +5 -2
- package/src/jslt/body.js +226 -0
- package/src/jslt/index.js +18 -0
- package/src/jslt/rules.js +202 -0
- package/src/json-boundary.js +90 -0
- package/src/migration/define.js +318 -0
- package/src/migration/index.js +15 -0
- package/src/migration/steps.js +244 -0
- package/src/model/collection.js +273 -0
- package/src/model/define.js +125 -0
- package/src/model/entity.js +307 -0
- package/src/model/index.js +47 -0
- package/src/model/relation.js +85 -0
- package/src/provider.js +137 -20
- package/src/schema/brand.js +31 -0
- package/src/schema/builders.js +526 -0
- package/src/schema/check.js +29 -0
- package/src/schema/emit.js +394 -0
- package/src/schema/factories.js +239 -0
- package/src/schema/index.js +37 -0
- package/src/schema-of.js +24 -0
- package/src/sequence.js +233 -103
- package/src/sources.js +10 -3
- package/types/app.d.ts +293 -0
- package/types/contract.d.ts +468 -0
- package/types/db.d.ts +359 -0
- package/types/flow.d.ts +285 -0
- package/types/forms.d.ts +253 -0
- package/types/index.d.ts +296 -26
- package/types/jslt.d.ts +193 -0
- package/types/migration.d.ts +201 -0
- package/types/model.d.ts +526 -0
- package/types/schema.d.ts +494 -0
package/docs/APP-PEN.md
ADDED
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
# The Jaren app pen
|
|
2
|
+
|
|
3
|
+
> `./app` — the `jaren-app` 0.1 document `createApp` runs, and the JSON
|
|
4
|
+
> Schema of its state beside it. **Read it when** you are declaring a
|
|
5
|
+
> whole application: state, view, actions, effects
|
|
6
|
+
|
|
7
|
+
Version 0.1. The key words MUST, MUST NOT, SHOULD and MAY are to be
|
|
8
|
+
interpreted as described in RFC 2119. This document is a **guide** — read
|
|
9
|
+
it in order and you can write the format — whose one normative section is
|
|
10
|
+
[§2 The mapping table](#2-the-mapping-table); the rules every pen keeps, the shared refusal table, the
|
|
11
|
+
index of the other pens and every pen's mapping table collected in one
|
|
12
|
+
place are the normative reference,
|
|
13
|
+
[LINQ-FORMAT.md](LINQ-FORMAT.md).
|
|
14
|
+
|
|
15
|
+
## 1. What it writes
|
|
16
|
+
|
|
17
|
+
An app document is a whole interactive application as one JSON value —
|
|
18
|
+
its state, the view that renders it, the actions that change it, the
|
|
19
|
+
subscriptions that feed it. Written by hand it is JSON Pointers in
|
|
20
|
+
strings, action names in strings, and query expressions in object
|
|
21
|
+
literals, none of which anything checks until the app runs. This pen
|
|
22
|
+
makes each of them a function call over the state you declared, so a
|
|
23
|
+
pointer is derived from the shape, an action name a view binds is checked
|
|
24
|
+
against the actions map, and a patch you can spell wrong does not
|
|
25
|
+
compile.
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { defineApp, action, transition, effect, bind, sub } from '@jarenjs/linq/app';
|
|
29
|
+
import { add, append, replace, remove, move, copy, test } from '@jarenjs/linq/app';
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
writes the `jaren-app` 0.1 document
|
|
33
|
+
([APP-FORMAT](../../app/docs/APP-FORMAT.md)) `createApp` runs — a whole
|
|
34
|
+
interactive application as one JSON value — and, beside it, the JSON
|
|
35
|
+
Schema of its state. The two are two members of ONE result,
|
|
36
|
+
`{ document, stateSchema }`, and are never merged: the format has no
|
|
37
|
+
slot for a state schema, and the hook that wants one is
|
|
38
|
+
`options.validateState` (APP-FORMAT §6), which is a `createApp` option.
|
|
39
|
+
|
|
40
|
+
Thirteen names, and they fall into four groups a reader can hold at
|
|
41
|
+
once: `defineApp()` writes the document; `action()`, `transition()`,
|
|
42
|
+
`effect()` write a transition; the seven patch operations write the
|
|
43
|
+
`patch` inside it; and `bind()` and `sub()` write the two places the
|
|
44
|
+
outside world reaches in — an event, and a subscription.
|
|
45
|
+
|
|
46
|
+
**The running example.** §3 is one project board, taken a feature at a
|
|
47
|
+
time: the tag list whose patch semantics catch everybody out first, the
|
|
48
|
+
estimate counter that is the smallest complete app, the card promotion
|
|
49
|
+
that exercises all seven patch operations, the filter box that reads the
|
|
50
|
+
event, the card list with an effect beside its patch, the live columns
|
|
51
|
+
that fan a subscription out per column, and the grid whose view binds
|
|
52
|
+
actions. Each fence is a whole document — that is what the gate runs —
|
|
53
|
+
and read in order they are the same application growing. §5 reads the
|
|
54
|
+
types back off it.
|
|
55
|
+
|
|
56
|
+
### 1.1 One document, one capture
|
|
57
|
+
|
|
58
|
+
An action is ONE query document over APP-FORMAT §3.1's three names —
|
|
59
|
+
`$` the whole state, `$event` the serializable event slice, `$payload`
|
|
60
|
+
the dispatch payload — so everything inside it is captured in one pass.
|
|
61
|
+
`transition()`, `effect()` and the patch helpers assemble plain objects
|
|
62
|
+
and leave the spelling to the capture already running; none of them
|
|
63
|
+
starts a capture of its own.
|
|
64
|
+
|
|
65
|
+
That is what makes the format's own guarantee expressible — the
|
|
66
|
+
increment in a patch and the one in an effect's props are the SAME
|
|
67
|
+
expression, because both evaluate against the pre-transition state
|
|
68
|
+
(§3.5 shows the two `{ "$add": ["$.pending", 1] }` nodes) — and it is
|
|
69
|
+
why an effect's props are a VALUE here where the flow pen's are a
|
|
70
|
+
callback ([FLOW-PEN.md](FLOW-PEN.md)): there, each guard is its own
|
|
71
|
+
document; here, the action is.
|
|
72
|
+
|
|
73
|
+
A view is not part of that capture. The `view` member is a JSLT
|
|
74
|
+
stylesheet ([JSLT-PEN.md](JSLT-PEN.md)), captured per rule body against
|
|
75
|
+
its own scope, and a `bind()` inside one is ordinary data the body
|
|
76
|
+
spells — which is what replaces a payload-creator function, because a
|
|
77
|
+
payload built at render time may embed the matched node's own members.
|
|
78
|
+
|
|
79
|
+
### 1.2 The initial state is derived
|
|
80
|
+
|
|
81
|
+
The initial state comes from the state builder's `default()`s unless
|
|
82
|
+
`initial` names one. A member contributes its `default` (or its
|
|
83
|
+
`const`); an object recurses; an object resolves when it is required or
|
|
84
|
+
when at least one of its own members did, so an optional block of
|
|
85
|
+
defaults appears and an optional empty one does not; and a REQUIRED
|
|
86
|
+
member that resolves to nothing is `JL0102` naming its pointer
|
|
87
|
+
([§4.3](#43-jl0102--the-state-the-pointer-and-the-binding)) — because a
|
|
88
|
+
state whose required member is absent fails its own `validateState` on
|
|
89
|
+
the boot transaction, which is a fatal `JA0007` and a bad way to learn
|
|
90
|
+
about a missing default.
|
|
91
|
+
|
|
92
|
+
The rule that follows is worth stating on its own, because it is what
|
|
93
|
+
the derivation MEANS: **the state a default describes is the state the
|
|
94
|
+
document carries.** There is no second source. If the document's initial
|
|
95
|
+
state is wrong, a `default()` is wrong or `initial` is wrong, and both
|
|
96
|
+
are visible in the source that wrote it.
|
|
97
|
+
|
|
98
|
+
### 1.3 The pen carries no engine
|
|
99
|
+
|
|
100
|
+
The pen imports nothing of `@jarenjs/app`, `@jarenjs/view`,
|
|
101
|
+
`@jarenjs/json` or `@jarenjs/validate`: the loop's compiler stays the
|
|
102
|
+
only judge of what an app means. `test/linq/app-pen.test.js` reads every
|
|
103
|
+
file of `packages/linq/src/app/` and asserts the import is absent, and
|
|
104
|
+
the tree-shaking probe (§7) measures the bundle that follows from it.
|
|
105
|
+
|
|
106
|
+
## 2. The mapping table
|
|
107
|
+
|
|
108
|
+
Every name `@jarenjs/linq/app` exports — all thirteen — and the members
|
|
109
|
+
each writes. The subpath exports no builder class, no constant and no
|
|
110
|
+
guard, so §5 documents the exported TYPES rather than a set of values a
|
|
111
|
+
caller meets.
|
|
112
|
+
|
|
113
|
+
Status: **native** (emits the named member), **emulated** (a composition
|
|
114
|
+
with identical semantics), **refused** (a coded error naming the
|
|
115
|
+
reason).
|
|
116
|
+
|
|
117
|
+
### 2.1 The document
|
|
118
|
+
|
|
119
|
+
The one call that assembles an application, and the two members it
|
|
120
|
+
answers.
|
|
121
|
+
|
|
122
|
+
| Method | Emits | Type reading | Status |
|
|
123
|
+
|---|---|---|---|
|
|
124
|
+
| `defineApp({ state, initial?, schema?, view, actions?, subs? })` | `{ document: { $app: '0.1', state?, view, actions?, subs? }, stateSchema }` — only the members the author declared, deep-frozen | `AppResult<State, Actions>`; `StateOf<>` and `ActionsOf<>` read it back | native; a member the pen does not know, a missing `view`, a `schema` beside a builder state `JL0101`; an underivable initial state, or a view binding an undeclared action, `JL0102` |
|
|
125
|
+
| `state` | the initial value, from the builder's `default()`s (§1.2); the builder itself becomes `stateSchema` | `Infer<B>` | native |
|
|
126
|
+
| `initial` | the initial value verbatim, in place of the derivation | `Infer<B>` | native; a non-JSON value `JL0101` |
|
|
127
|
+
| `schema` | nothing — it TYPES a `state` given as a plain JSON value, and becomes `stateSchema` | `Infer<B>` | native; beside a builder state, `JL0101` (a builder state IS its schema) |
|
|
128
|
+
| `view` | the JSLT stylesheet verbatim — the pen's `stylesheet([rule(…)])` envelope ([JSLT-PEN.md](JSLT-PEN.md)) or APP-FORMAT §2's bare rule array | `unknown`: a view is a document the grammar judges | native |
|
|
129
|
+
| `actions` | the `actions` map, one captured action document per name | `keyof A & string` — the literal names, which is what `bind<>()` is checked against | native; a value that is not an `action()` `JL0101` |
|
|
130
|
+
| `subs` | the `subs` array, one `sub()` entry per element | `readonly SubDeclaration[]` | native; a value that is not a `sub()` `JL0101` |
|
|
131
|
+
|
|
132
|
+
### 2.2 The transition
|
|
133
|
+
|
|
134
|
+
What an action returns: the patch to apply, the effects to hand the host,
|
|
135
|
+
and nothing else — a transition is data, so a test can read it.
|
|
136
|
+
|
|
137
|
+
| Method | Emits | Type reading | Status |
|
|
138
|
+
|---|---|---|---|
|
|
139
|
+
| `action(fn, { payload?, event? })` | one action document, captured over `$`, `$event`, `$payload` | `ActionDeclaration<Payload>`; `payload` and `event` are TYPES — the format carries no schema for either, and nothing is emitted for them | native; a non-builder `payload` `JL0101`; an excluded `event` field `JL0102`; a name §3.1 does not bind `JL0104` |
|
|
140
|
+
| `transition({ state?, patch?, effects? })` | the transition object of APP-FORMAT §3.2, in the order the runtime applies it | `Transition` | native; another member `JL0101` |
|
|
141
|
+
| `effect(run, with?)` | `{ run, with? }` (§5.1); `with` is a value in the ACTION's scope, not a callback | `EffectDeclaration<Run>` — `Run` is a literal | native; an empty `run` `JL0101` |
|
|
142
|
+
|
|
143
|
+
Returning nothing from an action is the format's own no-op, and
|
|
144
|
+
`transition({})` is that same empty object: it is allowed, and it is
|
|
145
|
+
what an action that only fires an effect on a later turn writes.
|
|
146
|
+
|
|
147
|
+
### 2.3 The seven patch operations
|
|
148
|
+
|
|
149
|
+
A `path` is a captured lambda over the state — `(st) => st.todos` is
|
|
150
|
+
`/todos` — so a pointer is DERIVED from the state shape and a typo is a
|
|
151
|
+
capture error rather than a silent `JA2004` at dispatch. A pointer
|
|
152
|
+
string is accepted verbatim for the locations a shape cannot spell.
|
|
153
|
+
|
|
154
|
+
| Method | Emits | Type reading | Status |
|
|
155
|
+
|---|---|---|---|
|
|
156
|
+
| `add(path, value)` | `{ op: 'add', path, value }` — **sets a member, and REPLACES an array when the path names one** | `PatchOp` | native |
|
|
157
|
+
| `append(path, value)` | `{ op: 'add', path: '<path>/-', value }` — RFC 6902's array APPEND, the same op at the array's `-` position | `PatchOp` | native |
|
|
158
|
+
| `replace(path, value)` | `{ op: 'replace', path, value }` — the op a transition writes most, and the one an array ELEMENT needs | `PatchOp` | native |
|
|
159
|
+
| `remove(path)` | `{ op: 'remove', path }` | `PatchOp` | native |
|
|
160
|
+
| `move(from, path)` | `{ op: 'move', from, path }` — both lowered as pointers | `PatchOp` | native |
|
|
161
|
+
| `copy(from, path)` | `{ op: 'copy', from, path }` | `PatchOp` | native |
|
|
162
|
+
| `test(path, value)` | `{ op: 'test', path, value }` — a failing test aborts the WHOLE transition (`JA2004`), which is the format's own way to write a precondition | `PatchOp` | native |
|
|
163
|
+
| a path lambda `(st, x) => …` | the JSON Pointer the state shape describes: `st.todos` → `/todos`, `st.todos.at(2).done` → `/todos/2/done`, `st.get('a/b')` → `/a~1b` (RFC 6901 escaping) | `PatchPath<State, Payload>` — annotate to type it | native |
|
|
164
|
+
| a path lambda with a COMPUTED index | the pointer as a string EXPRESSION, `{ "$concat": ["/todos/", <index>, "/done"] }` — APP-FORMAT §3.2's "op members like `value` and `path` are themselves query expressions" | the same | native; anything that is not a chain of member reads and subscripts `JL0102` |
|
|
165
|
+
| a path as a string | the pointer verbatim, `+ '/-'` under `append()` | `string` | native; a string that does not start with `/` `JL0102` |
|
|
166
|
+
|
|
167
|
+
**`add()` REPLACES an array; `append()` adds to it.** This is the pen's
|
|
168
|
+
single most misread pair and the distinction is RFC 6902's, not the
|
|
169
|
+
pen's: a pointer that NAMES a member replaces that member, so
|
|
170
|
+
`add((st) => st.todos, item)` sets `/todos` to the item and drops the
|
|
171
|
+
list. The append is `add` at the array's `-` position, `/todos/-`, and
|
|
172
|
+
it has its own name here because the difference costs a `validateState`
|
|
173
|
+
rejection at dispatch time to find out otherwise. [§3.1](#31-addpath-value-replaces-an-array-appendpath-value-adds-to-it)
|
|
174
|
+
shows both emitted documents side by side.
|
|
175
|
+
|
|
176
|
+
**The one pointer this pen computes.** Every other path is written as
|
|
177
|
+
pointer TEXT at build time. A non-literal index cannot be — so
|
|
178
|
+
`st.todos.at(x.payload.i).done` emits
|
|
179
|
+
`{ "$concat": ["/todos/", "$payload.i", "/done"] }`, adjacent literal
|
|
180
|
+
segments merged into one operand. (`$string-join` joins a SEQUENCE with
|
|
181
|
+
a separator and casts an array constructor's array to a string —
|
|
182
|
+
`JQ2001`; `$concat` is the operator that concatenates operands, and it
|
|
183
|
+
is what a pointer needs.) The `$concat` node is LIFTED as an operator
|
|
184
|
+
rather than written as data, because a `$`-keyed object in a captured
|
|
185
|
+
tree is otherwise a constructor (the `$map` escape).
|
|
186
|
+
|
|
187
|
+
### 2.4 The two doors in
|
|
188
|
+
|
|
189
|
+
The two places the outside world reaches an app: an event a view binds,
|
|
190
|
+
and a subscription the host runs.
|
|
191
|
+
|
|
192
|
+
| Method | Emits | Type reading | Status |
|
|
193
|
+
|---|---|---|---|
|
|
194
|
+
| `bind(name, { payload?, event?, preventDefault?, stopPropagation? })` | APP-FORMAT §4's object binding — `{ action, with?, event?, preventDefault?, stopPropagation? }`, in the format's member order | `Binding<Names>` — annotate the call (`bind<Action>('todo/add')`) and an undeclared name stops compiling | native; an empty name, an unknown member or a non-boolean control `JL0101`; a field §3.1 excludes `JL0102` |
|
|
195
|
+
| `sub(run, { with?, when?, withQuery?, key?, for? })` | one APP-FORMAT §5.3 entry, in the format's member order | `SubDeclaration<Run>` | native; an empty `run`, an unknown member or a callback under `with` `JL0101`; a combination §5.3 calls `JA0008` `JL0102`; `$item` outside a `for` `JL0104` |
|
|
196
|
+
|
|
197
|
+
**`with` is data; `withQuery`, `key` and `for` are queries.** That is the
|
|
198
|
+
distinction the format builds its whole restart rule out of, and the pen
|
|
199
|
+
makes it at the door: `with` is verbatim and never evaluated, so a
|
|
200
|
+
static entry never restarts; a callback under it is `JL0101` before the
|
|
201
|
+
runtime would hand a function to a handler as props. A member that is
|
|
202
|
+
sometimes data and sometimes executable is how a document becomes
|
|
203
|
+
accidentally executable.
|
|
204
|
+
|
|
205
|
+
The three combinations §5.3 calls `JA0008` are refused here, naming the
|
|
206
|
+
same reason, because the pen can see all three members at once:
|
|
207
|
+
`with` beside `withQuery`, `with` beside `for`, and `key` without either
|
|
208
|
+
([§4.3](#43-jl0102--the-state-the-pointer-and-the-binding)).
|
|
209
|
+
|
|
210
|
+
### 2.5 The `$event` allow-list
|
|
211
|
+
|
|
212
|
+
A binding may request any field name: the built-in allow-list of
|
|
213
|
+
APP-FORMAT §3.1, or a host extractor's own, since an extractor
|
|
214
|
+
registered under a name WINS over the built-in list (§3.1's precedence
|
|
215
|
+
order) and the pen cannot see the host's registry. Requesting a name
|
|
216
|
+
nothing answers is `JA2009` at dispatch — the member is bound `null` and
|
|
217
|
+
the dispatch is NOT dropped — which is the runtime's to report.
|
|
218
|
+
|
|
219
|
+
What the pen refuses is the set §3.1 excludes BY CONSTRUCTION, fourteen
|
|
220
|
+
names: `target`, `currentTarget`, `relatedTarget`, `srcElement`, `view`,
|
|
221
|
+
`files`, `dataTransfer`, `touches`, `targetTouches`, `changedTouches`,
|
|
222
|
+
`path`, `composedPath`, `clipboardData`, `submitter`. The reason is one
|
|
223
|
+
sentence and it is the invariant, not a policy: **`$event` MUST survive
|
|
224
|
+
`JSON.stringify`, the same invariant as state.** A host object never
|
|
225
|
+
enters it. [§4.3](#43-jl0102--the-state-the-pointer-and-the-binding)
|
|
226
|
+
carries the message and the way through.
|
|
227
|
+
|
|
228
|
+
### 2.6 What the pen does not judge
|
|
229
|
+
|
|
230
|
+
By design, these are the loop's, and the pen emits documents that reach
|
|
231
|
+
them: an unregistered effect or subscription name (`JA2006`, `JA2008`),
|
|
232
|
+
an action or `when` document that throws while evaluating (`JA2002`), a
|
|
233
|
+
patch that fails to apply (`JA2004`), the state invariant (`JA2005`), a
|
|
234
|
+
requested event field nothing answers (`JA2009`), a dynamic
|
|
235
|
+
subscription query that throws (`JA2016`), a fan-out past
|
|
236
|
+
`maxSubInstances` (`JA2017`) and everything else APP-FORMAT §10 lists.
|
|
237
|
+
`test/linq/app-pen.test.js` builds an app through the pen whose only
|
|
238
|
+
effect name is unregistered, boots it under `createApp` and asserts the
|
|
239
|
+
loop reports `JA2006` — the pen's silence there is a tested property,
|
|
240
|
+
not an omission.
|
|
241
|
+
|
|
242
|
+
## 3. Worked examples
|
|
243
|
+
|
|
244
|
+
Every `js` fence exports exactly one document, and the `json` fence that
|
|
245
|
+
follows is what the pen emits — executed by
|
|
246
|
+
`test/linq/pen-docs.test.js`. APP-FORMAT §2's document and the
|
|
247
|
+
`contract/catalog.load/start` action CONTRACT-FORMAT §11.1 generates are
|
|
248
|
+
rebuilt the same way and held BYTE-equal to those docs' own fences by
|
|
249
|
+
`test/linq/app-pen.test.js`, which also boots every example shape under
|
|
250
|
+
`createApp` with a headless host and dispatches into it. The same suite
|
|
251
|
+
boots an emitted document beside its hand-written twin and compares the
|
|
252
|
+
two applications frame for frame — the pen's document is the same
|
|
253
|
+
application, not merely the same bytes.
|
|
254
|
+
|
|
255
|
+
### 3.1 `add(path, value)` replaces an array; `append(path, value)` adds to it
|
|
256
|
+
|
|
257
|
+
**The board's tag list**, and the trap everyone meets first.
|
|
258
|
+
Two actions, one array, one document — because the only way to read the
|
|
259
|
+
difference is to see both pointers beside each other:
|
|
260
|
+
|
|
261
|
+
```js
|
|
262
|
+
import { action, add, append, defineApp, transition } from '@jarenjs/linq/app';
|
|
263
|
+
import { rule } from '@jarenjs/linq/jslt';
|
|
264
|
+
import * as s from '@jarenjs/linq/schema';
|
|
265
|
+
|
|
266
|
+
export const list = defineApp({
|
|
267
|
+
state: s.object({ tags: s.array(s.string()).default([]) }),
|
|
268
|
+
view: [rule('$', (v) => ['ul', {}, v.tags.all()])],
|
|
269
|
+
actions: {
|
|
270
|
+
// RFC 6902 'add' at a path that NAMES the array replaces the array
|
|
271
|
+
'tags/set': action((st, x) => transition({ patch: [add((c) => c.tags, x.payload)] })),
|
|
272
|
+
// 'add' at the array's '-' position appends one element to it
|
|
273
|
+
'tags/append': action((st, x) => transition({ patch: [append((c) => c.tags, x.payload)] })),
|
|
274
|
+
},
|
|
275
|
+
}).document;
|
|
276
|
+
```
|
|
277
|
+
```json
|
|
278
|
+
{
|
|
279
|
+
"$app": "0.1",
|
|
280
|
+
"state": { "tags": [] },
|
|
281
|
+
"view": [{ "match": "$", "body": ["ul", {}, "$.tags[*]"] }],
|
|
282
|
+
"actions": {
|
|
283
|
+
"tags/set": { "patch": [{ "op": "add", "path": "/tags", "value": "$payload" }] },
|
|
284
|
+
"tags/append": { "patch": [{ "op": "add", "path": "/tags/-", "value": "$payload" }] }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Both operations are `"op": "add"`. The only difference in the emitted
|
|
290
|
+
document is the last two characters of the pointer, `/-`, and that is
|
|
291
|
+
the whole of it:
|
|
292
|
+
|
|
293
|
+
| Dispatched with | `tags/set` leaves | `tags/append` leaves |
|
|
294
|
+
|---|---|---|
|
|
295
|
+
| `'a'` on `[]` | `"a"` — the array is GONE, replaced by a string | `["a"]` |
|
|
296
|
+
| `'c'` on `['a', 'b']` | `"c"` | `["a", "b", "c"]` |
|
|
297
|
+
|
|
298
|
+
`tags/set` is not a bug; it is the operation a reader wants when the
|
|
299
|
+
payload IS the new list (`add((c) => c.tags, x.payload)` where the
|
|
300
|
+
payload is an array replaces the list wholesale). It is a bug when the
|
|
301
|
+
payload is one element, and the state schema is what catches it — the
|
|
302
|
+
next state fails `validateState`, the transition is blocked, and the
|
|
303
|
+
loop reports `JA2005`. Reaching for `append()` is how a reader never
|
|
304
|
+
sees that.
|
|
305
|
+
|
|
306
|
+
`replace()` and `add()` differ once more, at an array ELEMENT:
|
|
307
|
+
`add((c) => c.tags.at(1), x)` INSERTS at index 1 and shifts the rest,
|
|
308
|
+
where `replace((c) => c.tags.at(1), x)` overwrites it. On an object
|
|
309
|
+
member the two are the same operation.
|
|
310
|
+
|
|
311
|
+
### 3.2 An app with state, one action and a view
|
|
312
|
+
|
|
313
|
+
**The board's estimate counter**, and the smallest app that is a whole
|
|
314
|
+
one. The whole shape in one document: a state whose defaults ARE the initial
|
|
315
|
+
value, a view that reads it and binds an action, and an action that
|
|
316
|
+
patches it.
|
|
317
|
+
|
|
318
|
+
```js
|
|
319
|
+
import { action, bind, defineApp, replace, transition } from '@jarenjs/linq/app';
|
|
320
|
+
import { rule } from '@jarenjs/linq/jslt';
|
|
321
|
+
import * as s from '@jarenjs/linq/schema';
|
|
322
|
+
|
|
323
|
+
export const counter = defineApp({
|
|
324
|
+
state: s.object({ total: s.integer().default(0), step: s.integer().default(1) }),
|
|
325
|
+
view: [rule('$', (v) => ['main', {},
|
|
326
|
+
['output', {}, v.total],
|
|
327
|
+
['button', { on: { click: bind('inc') } }, 'add'],
|
|
328
|
+
])],
|
|
329
|
+
actions: {
|
|
330
|
+
inc: action((st) => transition({ patch: [replace((c) => c.total, st.total.add(st.step))] })),
|
|
331
|
+
},
|
|
332
|
+
}).document;
|
|
333
|
+
```
|
|
334
|
+
```json
|
|
335
|
+
{
|
|
336
|
+
"$app": "0.1",
|
|
337
|
+
"state": { "total": 0, "step": 1 },
|
|
338
|
+
"view": [
|
|
339
|
+
{ "match": "$", "body": ["main", {},
|
|
340
|
+
["output", {}, "$.total"],
|
|
341
|
+
["button", { "on": { "click": { "action": "inc" } } }, "add"]] }
|
|
342
|
+
],
|
|
343
|
+
"actions": {
|
|
344
|
+
"inc": { "patch": [{ "op": "replace", "path": "/total", "value": { "$add": ["$.total", "$.step"] } }] }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Three readings in one document. `state` is `{ total: 0, step: 1 }`
|
|
350
|
+
because both members declare a `default()` — nothing else wrote it.
|
|
351
|
+
`v.total` in the view body captured as `"$.total"`, an ordinary JSLT
|
|
352
|
+
body over the state. And `st.total.add(st.step)` captured as
|
|
353
|
+
`{ "$add": ["$.total", "$.step"] }` — the action reads the whole state,
|
|
354
|
+
so a patch value may name any member, not just the one it writes.
|
|
355
|
+
|
|
356
|
+
`bind('inc')` emitted `{ "action": "inc" }` and nothing more:
|
|
357
|
+
`defineApp()` checked that name against `actions` before the document
|
|
358
|
+
existed ([§4.3](#43-jl0102--the-state-the-pointer-and-the-binding)).
|
|
359
|
+
|
|
360
|
+
### 3.3 A patch chain over a nested path
|
|
361
|
+
|
|
362
|
+
**Promoting a card between the board's columns.**
|
|
363
|
+
Every one of the seven operations, the pointer each writes, and the two
|
|
364
|
+
places a pointer is not plain text:
|
|
365
|
+
|
|
366
|
+
```js
|
|
367
|
+
import { action, copy, move, remove, replace, test, transition } from '@jarenjs/linq/app';
|
|
368
|
+
|
|
369
|
+
export const promote = action((st, x) => transition({
|
|
370
|
+
patch: [
|
|
371
|
+
test((c) => c.board.columns.at(0).cards.at(2).id, x.payload.id),
|
|
372
|
+
copy((c) => c.board.columns.at(0).cards.at(2), (c) => c.board.columns.at(1).cards.at(0)),
|
|
373
|
+
remove((c) => c.board.columns.at(0).cards.at(2)),
|
|
374
|
+
move((c) => c.board.get('draft/note'), (c) => c.board.get('a~b')),
|
|
375
|
+
replace((c, y) => c.board.columns.at(y.payload.to).title, 'Done'),
|
|
376
|
+
],
|
|
377
|
+
})).document;
|
|
378
|
+
```
|
|
379
|
+
```json
|
|
380
|
+
{
|
|
381
|
+
"patch": [
|
|
382
|
+
{ "op": "test", "path": "/board/columns/0/cards/2/id", "value": "$payload.id" },
|
|
383
|
+
{ "op": "copy", "from": "/board/columns/0/cards/2", "path": "/board/columns/1/cards/0" },
|
|
384
|
+
{ "op": "remove", "path": "/board/columns/0/cards/2" },
|
|
385
|
+
{ "op": "move", "from": "/board/draft~1note", "path": "/board/a~0b" },
|
|
386
|
+
{ "op": "replace",
|
|
387
|
+
"path": { "$concat": ["/board/columns/", "$payload.to", "/title"] },
|
|
388
|
+
"value": "Done" }
|
|
389
|
+
]
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
What to read off it:
|
|
394
|
+
|
|
395
|
+
- **A literal subscript is pointer text.** `at(0)`, `at(2)` became `/0`,
|
|
396
|
+
`/2` at build time; no expression survives into the document.
|
|
397
|
+
- **`get(name)` is how a member whose name is not an identifier is
|
|
398
|
+
reached**, and RFC 6901 escaping is the pen's: `draft/note` →
|
|
399
|
+
`draft~1note`, `a~b` → `a~0b`. It is also the escape for a member
|
|
400
|
+
named like a chain method ([§5.3](#53-a-member-named-like-a-chain-method-captures-as-the-method)).
|
|
401
|
+
- **`move()` and `copy()` lower BOTH pointers.** `from` is written
|
|
402
|
+
before `path`, which is RFC 6902's member order.
|
|
403
|
+
- **A computed subscript is the one expression a path carries**, and it
|
|
404
|
+
is a `$concat` over the literal segments and the index — evaluated at
|
|
405
|
+
dispatch, against the pre-transition state, like every other
|
|
406
|
+
expression in the action.
|
|
407
|
+
- **`test()` aborts the whole transition**, not its own operation. The
|
|
408
|
+
patch above is a precondition followed by four writes: if card 2 is
|
|
409
|
+
not the card the payload names, nothing at all happens and the loop
|
|
410
|
+
reports `JA2004`.
|
|
411
|
+
|
|
412
|
+
### 3.4 An action reading `$event`
|
|
413
|
+
|
|
414
|
+
**The board's filter box**, where the action reads the keystroke.
|
|
415
|
+
`$event` is the serializable slice of a DOM event: the default four
|
|
416
|
+
members (`type`, `value`, `checked`, `key`) plus one per field the
|
|
417
|
+
BINDING requested. The `action()`'s own `event` option requests nothing
|
|
418
|
+
at run time — it types `x.event` — so the two lists are declared in both
|
|
419
|
+
places when both are wanted.
|
|
420
|
+
|
|
421
|
+
```js
|
|
422
|
+
import { action, bind, defineApp, replace, transition } from '@jarenjs/linq/app';
|
|
423
|
+
import { rule } from '@jarenjs/linq/jslt';
|
|
424
|
+
import * as s from '@jarenjs/linq/schema';
|
|
425
|
+
|
|
426
|
+
export const search = defineApp({
|
|
427
|
+
state: s.object({
|
|
428
|
+
term: s.string().default(''),
|
|
429
|
+
exact: s.boolean().default(false),
|
|
430
|
+
lastKey: s.string().default(''),
|
|
431
|
+
}),
|
|
432
|
+
view: [rule('$', (v) => ['input', {
|
|
433
|
+
value: v.term,
|
|
434
|
+
on: {
|
|
435
|
+
input: bind('term/typed', { event: ['shiftKey'] }),
|
|
436
|
+
keydown: bind('term/key', { event: ['code'], preventDefault: true }),
|
|
437
|
+
},
|
|
438
|
+
}])],
|
|
439
|
+
actions: {
|
|
440
|
+
'term/typed': action((st, x) => transition({
|
|
441
|
+
patch: [
|
|
442
|
+
replace((c) => c.term, x.event.value),
|
|
443
|
+
replace((c) => c.exact, x.event.shiftKey),
|
|
444
|
+
],
|
|
445
|
+
}), { event: ['shiftKey'] }),
|
|
446
|
+
'term/key': action((st, x) => transition({
|
|
447
|
+
patch: [replace((c) => c.lastKey, x.event.code)],
|
|
448
|
+
}), { event: ['code'] }),
|
|
449
|
+
},
|
|
450
|
+
}).document;
|
|
451
|
+
```
|
|
452
|
+
```json
|
|
453
|
+
{
|
|
454
|
+
"$app": "0.1",
|
|
455
|
+
"state": { "term": "", "exact": false, "lastKey": "" },
|
|
456
|
+
"view": [
|
|
457
|
+
{ "match": "$", "body": ["input", {
|
|
458
|
+
"value": "$.term",
|
|
459
|
+
"on": {
|
|
460
|
+
"input": { "action": "term/typed", "event": ["shiftKey"] },
|
|
461
|
+
"keydown": { "action": "term/key", "event": ["code"], "preventDefault": true }
|
|
462
|
+
} }] }
|
|
463
|
+
],
|
|
464
|
+
"actions": {
|
|
465
|
+
"term/typed": { "patch": [
|
|
466
|
+
{ "op": "replace", "path": "/term", "value": "$event.value" },
|
|
467
|
+
{ "op": "replace", "path": "/exact", "value": "$event.shiftKey" }] },
|
|
468
|
+
"term/key": { "patch": [
|
|
469
|
+
{ "op": "replace", "path": "/lastKey", "value": "$event.code" }] }
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
`x.event.value` is the default slice's member and needed no request;
|
|
475
|
+
`x.event.shiftKey` and `x.event.code` are the built-in allow-list's, and
|
|
476
|
+
each binding asked for the one its action reads. `preventDefault` rides
|
|
477
|
+
on the binding, not on the action, because it is the DISPATCH's
|
|
478
|
+
behaviour and the binding is where the format puts it.
|
|
479
|
+
|
|
480
|
+
What this document cannot ask for is `event.target` — see
|
|
481
|
+
[§2.5](#25-the-event-allow-list) and
|
|
482
|
+
[§4.3](#43-jl0102--the-state-the-pointer-and-the-binding).
|
|
483
|
+
|
|
484
|
+
### 3.5 An action reading `$payload`, with an effect in the same scope
|
|
485
|
+
|
|
486
|
+
**Adding a card**, where the patch and the effect read the same payload.
|
|
487
|
+
|
|
488
|
+
```js
|
|
489
|
+
import { action, append, defineApp, effect, replace, transition } from '@jarenjs/linq/app';
|
|
490
|
+
import { rule } from '@jarenjs/linq/jslt';
|
|
491
|
+
import * as s from '@jarenjs/linq/schema';
|
|
492
|
+
|
|
493
|
+
const Card = s.object({ id: s.string(), text: s.string(), done: s.boolean().default(false) });
|
|
494
|
+
|
|
495
|
+
export const cards = defineApp({
|
|
496
|
+
state: s.object({ cards: s.array(Card).default([]), pending: s.integer().default(0) }),
|
|
497
|
+
view: [rule('$', (v) => ['ul', {}, v.cards.all().text])],
|
|
498
|
+
actions: {
|
|
499
|
+
'card/add': action((st, x) => transition({
|
|
500
|
+
patch: [
|
|
501
|
+
append((c) => c.cards, { id: x.payload.id, text: x.payload.text, done: false }),
|
|
502
|
+
replace((c) => c.pending, st.pending.add(1)),
|
|
503
|
+
],
|
|
504
|
+
effects: [effect('persist', { id: x.payload.id, at: st.pending.add(1) })],
|
|
505
|
+
}), { payload: s.object({ id: s.string(), text: s.string() }) }),
|
|
506
|
+
},
|
|
507
|
+
}).document;
|
|
508
|
+
```
|
|
509
|
+
```json
|
|
510
|
+
{
|
|
511
|
+
"$app": "0.1",
|
|
512
|
+
"state": { "cards": [], "pending": 0 },
|
|
513
|
+
"view": [{ "match": "$", "body": ["ul", {}, "$.cards[*].text"] }],
|
|
514
|
+
"actions": {
|
|
515
|
+
"card/add": {
|
|
516
|
+
"patch": [
|
|
517
|
+
{ "op": "add", "path": "/cards/-",
|
|
518
|
+
"value": { "id": "$payload.id", "text": "$payload.text", "done": false } },
|
|
519
|
+
{ "op": "replace", "path": "/pending", "value": { "$add": ["$.pending", 1] } }
|
|
520
|
+
],
|
|
521
|
+
"effects": [
|
|
522
|
+
{ "run": "persist",
|
|
523
|
+
"with": { "id": "$payload.id", "at": { "$add": ["$.pending", 1] } } }
|
|
524
|
+
]
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
The two `{ "$add": ["$.pending", 1] }` nodes are the point. They are the
|
|
531
|
+
same expression written twice in the source and captured twice into the
|
|
532
|
+
document, and at dispatch they evaluate to the same number — because
|
|
533
|
+
both evaluate against the PRE-transition state, whatever order the
|
|
534
|
+
runtime applies the transition in. An effect that needs the value the
|
|
535
|
+
patch just wrote computes it, rather than reading it back.
|
|
536
|
+
|
|
537
|
+
`{ payload: s.object({ … }) }` emitted nothing. It types `x.payload`
|
|
538
|
+
(§5) and the format carries no schema for a payload, so declaring one
|
|
539
|
+
costs a document nothing.
|
|
540
|
+
|
|
541
|
+
### 3.6 A subscription with `for` and `$item`
|
|
542
|
+
|
|
543
|
+
**The board's live columns.** A static entry and a dynamic fan-out in one document:
|
|
544
|
+
|
|
545
|
+
```js
|
|
546
|
+
import { defineApp, sub } from '@jarenjs/linq/app';
|
|
547
|
+
import { rule } from '@jarenjs/linq/jslt';
|
|
548
|
+
import * as s from '@jarenjs/linq/schema';
|
|
549
|
+
|
|
550
|
+
export const chat = defineApp({
|
|
551
|
+
state: s.object({
|
|
552
|
+
online: s.boolean().default(false),
|
|
553
|
+
columns: s.array(s.object({ id: s.string(), unread: s.integer().default(0) })).default([]),
|
|
554
|
+
}),
|
|
555
|
+
view: [rule('$', (v) => ['ul', {}, v.columns.all().id])],
|
|
556
|
+
subs: [
|
|
557
|
+
// static: verbatim props, never restarts
|
|
558
|
+
sub('clock', { with: { ms: 1000, tick: 'clock/tick' } }),
|
|
559
|
+
// dynamic fan-out: one instance per column, keyed by the column's id
|
|
560
|
+
sub('column', {
|
|
561
|
+
when: (st) => st.online,
|
|
562
|
+
for: (st) => st.columns.all(),
|
|
563
|
+
withQuery: (st, x) => ({ id: x.item.id, since: st.columns.all().unread.sum() }),
|
|
564
|
+
key: (st, x) => x.item.id,
|
|
565
|
+
}),
|
|
566
|
+
],
|
|
567
|
+
}).document;
|
|
568
|
+
```
|
|
569
|
+
```json
|
|
570
|
+
{
|
|
571
|
+
"$app": "0.1",
|
|
572
|
+
"state": { "online": false, "columns": [] },
|
|
573
|
+
"view": [{ "match": "$", "body": ["ul", {}, "$.columns[*].id"] }],
|
|
574
|
+
"subs": [
|
|
575
|
+
{ "run": "clock", "with": { "ms": 1000, "tick": "clock/tick" } },
|
|
576
|
+
{ "run": "column",
|
|
577
|
+
"when": "$.online",
|
|
578
|
+
"withQuery": { "id": "$item.id", "since": { "$sum": "$.columns[*].unread" } },
|
|
579
|
+
"key": "$item.id",
|
|
580
|
+
"for": "$.columns[*]" }
|
|
581
|
+
]
|
|
582
|
+
}
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
The members come out in APP-FORMAT §5.3's order — `run`, `with`, `when`,
|
|
586
|
+
`withQuery`, `key`, `for` — whatever order the author wrote them in.
|
|
587
|
+
|
|
588
|
+
`x.item` is bound in `withQuery` and `key`, and ONLY under a `for`: the
|
|
589
|
+
scope the pen captures against is exactly §5.3's closed world, so `when`
|
|
590
|
+
and `for` see the state alone and `$item` there is `JL0104` at build
|
|
591
|
+
time rather than `JA0008` at `createApp` time
|
|
592
|
+
([§4.4](#44-jl0104--the-closed-worlds)). `withQuery` may read both, as
|
|
593
|
+
`since` does: `$item.id` beside a query over the whole state.
|
|
594
|
+
|
|
595
|
+
The two entries also show the restart rule from the document's side. The
|
|
596
|
+
`clock` entry has `with` and no query, so it never restarts. The `column`
|
|
597
|
+
entry restarts one instance whenever its `key` changes, and
|
|
598
|
+
`stableStringify(item)` would have been the key had none been declared —
|
|
599
|
+
`key` is the opt-out from recomputing a deep key per transaction.
|
|
600
|
+
|
|
601
|
+
### 3.7 A view binding an action, and the refusal when it is undeclared
|
|
602
|
+
|
|
603
|
+
**The board's grid**, and what a view may bind.
|
|
604
|
+
APP-FORMAT §4 gives a binding two forms and the pen reads both: a string
|
|
605
|
+
under an `on` map, and an object carrying an `action` member ANYWHERE,
|
|
606
|
+
because that member is §4's vocabulary and a widget's `emit` will find
|
|
607
|
+
one wherever it sits.
|
|
608
|
+
|
|
609
|
+
```js
|
|
610
|
+
import { action, bind, defineApp, remove, transition } from '@jarenjs/linq/app';
|
|
611
|
+
import { rule } from '@jarenjs/linq/jslt';
|
|
612
|
+
import * as s from '@jarenjs/linq/schema';
|
|
613
|
+
|
|
614
|
+
export const table = defineApp({
|
|
615
|
+
state: s.object({ rows: s.array(s.object({ id: s.string() })).default([]), mode: s.string().default('view') }),
|
|
616
|
+
view: [rule('$', (v) => ['table', {},
|
|
617
|
+
// a literal name: the pen checks it against `actions`
|
|
618
|
+
['button', { on: { click: bind('row/clear', { stopPropagation: true }) } }, 'clear'],
|
|
619
|
+
// an object binding anywhere is a binding — a widget's `emit` finds it here
|
|
620
|
+
['x-grid', { rowAction: bind('row/drop', { payload: { id: v.rows.all().id } }) }],
|
|
621
|
+
// a `$`-prefixed value names the action at RENDER time; no pen can resolve it
|
|
622
|
+
['button', { on: { click: v.mode } }, 'go'],
|
|
623
|
+
])],
|
|
624
|
+
actions: {
|
|
625
|
+
'row/clear': action(() => transition({ patch: [remove('/rows')] })),
|
|
626
|
+
'row/drop': action((st, x) => transition({ patch: [remove((c, y) => c.rows.at(y.payload.id))] })),
|
|
627
|
+
},
|
|
628
|
+
}).document;
|
|
629
|
+
```
|
|
630
|
+
```json
|
|
631
|
+
{
|
|
632
|
+
"$app": "0.1",
|
|
633
|
+
"state": { "rows": [], "mode": "view" },
|
|
634
|
+
"view": [
|
|
635
|
+
{ "match": "$", "body": ["table", {},
|
|
636
|
+
["button", { "on": { "click": { "action": "row/clear", "stopPropagation": true } } }, "clear"],
|
|
637
|
+
["x-grid", { "rowAction": { "action": "row/drop", "with": { "id": "$.rows[*].id" } } }],
|
|
638
|
+
["button", { "on": { "click": "$.mode" } }, "go"]] }
|
|
639
|
+
],
|
|
640
|
+
"actions": {
|
|
641
|
+
"row/clear": { "patch": [{ "op": "remove", "path": "/rows" }] },
|
|
642
|
+
"row/drop": { "patch": [{ "op": "remove",
|
|
643
|
+
"path": { "$concat": ["/rows/", "$payload.id"] } }] }
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
Rename `row/drop` to `row/dropped` in `actions`, leave the view alone,
|
|
649
|
+
and the pen refuses the document before it exists — naming what IS
|
|
650
|
+
declared, so a typo is one glance from fixed:
|
|
651
|
+
|
|
652
|
+
```text
|
|
653
|
+
JL0102: the view binds the action 'row/drop', which "actions" does not
|
|
654
|
+
declare — the runtime drops such a dispatch and reports JA2001
|
|
655
|
+
(APP-FORMAT §4), so the user's click does nothing; the declared actions
|
|
656
|
+
are 'row/clear', 'row/dropped' at /view
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
The third binding is why the check has a hole in it, honestly: a value
|
|
660
|
+
starting with `$` is a query expression naming the action at render
|
|
661
|
+
time, and no pen can resolve it — the state decides which action a
|
|
662
|
+
render-time binding dispatches. Those the pen skips, and the runtime
|
|
663
|
+
checks them per dispatch as it always did.
|
|
664
|
+
|
|
665
|
+
## 4. Refusals
|
|
666
|
+
|
|
667
|
+
The app pen raises these `LinqBuildError` codes and no others —
|
|
668
|
+
`test/linq/pen-docs.test.js` holds this list equal, in both directions,
|
|
669
|
+
to the codes `packages/linq/src/app/` throws. The full condition each
|
|
670
|
+
code states across every pen is the binder's,
|
|
671
|
+
[LINQ-FORMAT.md](LINQ-FORMAT.md) §1.3.
|
|
672
|
+
|
|
673
|
+
| Code | What this pen raises it for |
|
|
674
|
+
|---|---|
|
|
675
|
+
| `JL0101` | a value this pen cannot spell, or a name → value map it cannot read |
|
|
676
|
+
| `JL0102` | a construct the format cannot carry |
|
|
677
|
+
| `JL0104` | a pen-owned keyword written through `meta()`, or an external a captured rule did not declare |
|
|
678
|
+
|
|
679
|
+
Every message below is the one the pen raised when the spelling beside
|
|
680
|
+
it was run, with the code prefix removed. `docPath`, where the refusal
|
|
681
|
+
carries one, is the JSON pointer of the node being assembled and is
|
|
682
|
+
appended to the message text as well (`… at /actions/a`).
|
|
683
|
+
|
|
684
|
+
### 4.1 `JL0101` — the document's own members
|
|
685
|
+
|
|
686
|
+
Raised at the door, before anything is captured.
|
|
687
|
+
|
|
688
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
689
|
+
|---|---|---|
|
|
690
|
+
| `defineApp('$')` | `defineApp() takes { state, initial?, schema?, view, actions?, subs? }, got a string` | a plain object |
|
|
691
|
+
| `defineApp({ state: {}, nope: 1, view: [] })` | `defineApp() does not take 'nope' — it takes state, initial, schema, view, actions, subs` | one of the six |
|
|
692
|
+
| `defineApp({ state: {} })` | `defineApp() needs a view — a JSLT stylesheet, from stylesheet([rule(…)]) or as a bare rule array; the format requires the member and the runtime refuses an app without one (JA0002)` | `view: [rule('$', …)]` |
|
|
693
|
+
| `defineApp({ state: {}, view: 'x' })` | `defineApp() view is a JSLT stylesheet document or a bare rule array, got a string` | an array, or a `stylesheet()` envelope |
|
|
694
|
+
| `defineApp({ state: s.object({}), schema: s.object({}), view: [] })` | `defineApp() takes 'schema' beside a state given as a plain JSON value — a state given as a builder IS its schema` | drop the `schema` |
|
|
695
|
+
| `defineApp({ state: {}, schema: {}, view: [] })` | `defineApp() schema is a schema-pen builder, got a Object instance` | `s.object({ … })` |
|
|
696
|
+
| `defineApp({ …, actions: [] })` | `defineApp() actions is an object of named action() declarations, got a Array instance` | a plain object |
|
|
697
|
+
| `defineApp({ …, actions: { a: { patch: [] } } })` | `defineApp() action 'a' is action((s, x) => transition(…)), got a Object instance` | `action(fn)` |
|
|
698
|
+
| `defineApp({ …, subs: 1 })`, `subs: [{ run: 'x' }]` | `defineApp() subs is an array of sub() declarations, got 1`; `defineApp() subs[0] is sub(run, options?), got a Object instance` | `sub('x')` |
|
|
699
|
+
| `defineApp({ state: () => 1, view: [] })`, the same under `initial` | `defineApp() state received a function, which is not JSON — a document carries null, booleans, finite numbers (never -0), strings, arrays and plain objects, and nothing else` | a JSON value, or a builder |
|
|
700
|
+
|
|
701
|
+
### 4.2 `JL0101` — the transition, the binding and the subscription
|
|
702
|
+
|
|
703
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
704
|
+
|---|---|---|
|
|
705
|
+
| `action(7)` | `action() takes a callback (s, x) => transition(…), got 7` | a callback |
|
|
706
|
+
| `action(fn, { nope: 1 })` | `action() does not take 'nope' — it takes payload, event` | `payload`, `event` |
|
|
707
|
+
| `action(fn, { payload: {} })` | `action() payload is a schema-pen builder — it types the dispatch's $payload, and the app format carries no schema for it, so nothing is emitted for it; got a Object instance` | `payload: s.object({ … })` |
|
|
708
|
+
| `action(fn, { event: 'x' })`, `{ event: [1] }` | `action() event is an array of field names (APP-FORMAT §4), got a string`; `action() event[0] is a field name, got 1` | `event: ['shiftKey']` |
|
|
709
|
+
| `transition(7)`, `transition({ nope: 1 })` | `transition() takes { state?, patch?, effects? }, got 7`; `transition() does not take 'nope' — it takes state, patch, effects` | one of the three |
|
|
710
|
+
| `transition({ patch: 'x' })`, `{ patch: [1] }` | `transition() patch is an array of add/replace/remove/move/copy/test operations, got a string`; `transition() patch[0] is one of add/replace/remove/move/copy/test, got 1` | an array of operations |
|
|
711
|
+
| `transition({ effects: [{ run: 'x' }] })` | `transition() effects[0] is effect(run, with?), got a Object instance` | `effect('x')` |
|
|
712
|
+
| `effect('')`, `bind('')`, `sub('')` | `effect() takes the handler name as a non-empty string, got a string`; `bind() takes an action name as a non-empty string, …`; `sub() takes the handler name as a non-empty string, …` | a non-empty name |
|
|
713
|
+
| `bind('a', { nope: 1 })` | `bind() does not take 'nope' — it takes payload, event, preventDefault, stopPropagation` | one of the four |
|
|
714
|
+
| `bind('a', { preventDefault: 'yes' })` | `bind() preventDefault is a boolean — it is allowed only on the object binding form and defaults to false (APP-FORMAT §4), got a string` | `preventDefault: true` |
|
|
715
|
+
| `sub('a', { nope: 1 })` | `sub() does not take 'nope' — it takes with, when, withQuery, key, for (APP-FORMAT §5.3)` | one of the five |
|
|
716
|
+
| `sub('a', { with: (st) => st.ms })` | `sub() 'with' is VERBATIM data handed to the handler, never evaluated — for props derived from the state write 'withQuery' instead (APP-FORMAT §5.3)` | `withQuery: (st) => …` |
|
|
717
|
+
| `replace(7, 1)` | `a patch path is a lambda over the state — (st) => st.todos — or a JSON Pointer string, got 7` | a lambda, or a pointer |
|
|
718
|
+
|
|
719
|
+
**The `__proto__` case.** `actions` and the state's `properties` are both
|
|
720
|
+
name → value maps, so the binder's §1.1 rule 5 applies to them:
|
|
721
|
+
|
|
722
|
+
```js
|
|
723
|
+
defineApp({ state: {}, view: [], actions: { __proto__: action(fn) } })
|
|
724
|
+
// JL0101: defineApp() actions received a map whose prototype was
|
|
725
|
+
// replaced: a '__proto__:' key in an object literal sets the prototype
|
|
726
|
+
// instead of adding a member, so that member is not there to emit —
|
|
727
|
+
// spell it { ['__proto__']: … }, which is an own key at /actions
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
The computed key IS an own property, and then `__proto__` is ORDINARY
|
|
731
|
+
DATA all the way through this pen — which the app pen is where the
|
|
732
|
+
campaign proves end to end, because it is the one pen whose document
|
|
733
|
+
carries a name map on both sides at once:
|
|
734
|
+
|
|
735
|
+
```js
|
|
736
|
+
defineApp({
|
|
737
|
+
state: s.object({ ['__proto__']: s.integer().default(7), n: s.integer().default(0) }),
|
|
738
|
+
view: [rule('$', () => ['p', {}, 'x'])],
|
|
739
|
+
actions: { ['__proto__']: action(() => transition({})), ok: action(() => transition({})) },
|
|
740
|
+
}).document
|
|
741
|
+
// { "$app": "0.1", "state": { "__proto__": 7, "n": 0 }, "view": [ … ],
|
|
742
|
+
// "actions": { "__proto__": {}, "ok": {} } }
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
An action named `__proto__` is a declared action; a state member named
|
|
746
|
+
`__proto__` gets its default into the initial state like any other. Both
|
|
747
|
+
survive because the pen writes them with `setObjectMember` — a plain
|
|
748
|
+
`out[name] = value` would reassign the emitted object's prototype and
|
|
749
|
+
drop the member. `test/linq/pen-docs.test.js` asserts the emitted
|
|
750
|
+
`actions` and `state` still carry `Object.prototype`, still hold
|
|
751
|
+
`__proto__` as an OWN member, and that `state['__proto__']` is `7`.
|
|
752
|
+
|
|
753
|
+
### 4.3 `JL0102` — the state, the pointer and the binding
|
|
754
|
+
|
|
755
|
+
Four conditions, and a reader meets three of them in their first
|
|
756
|
+
afternoon.
|
|
757
|
+
|
|
758
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
759
|
+
|---|---|---|
|
|
760
|
+
| `defineApp({ state: s.object({ id: s.string() }), view: [] })` | `the initial state cannot be derived: '/id' is required and declares no default — give the member a default(), or pass the whole initial state as defineApp()'s 'initial'` | `s.string().default('')`, or `initial: { id: '' }` |
|
|
761
|
+
| `replace((st) => st.todos.all().count(), 1)` | `a patch path is a JSON Pointer, and this one cannot be written as one: {"$count":"$.todos[*]"} — a path lambda reads members and subscripts off the state (st.todos.at(2).done, st.todos.at(x.payload.i)); anything else, pass the pointer as a string` | `st.todos.at(i)`, or a pointer string |
|
|
762
|
+
| `replace('a/b', 1)` | `a patch path given as a string is an RFC 6901 JSON Pointer — 'a/b' does not start with '/'; write a lambda over the state instead, (st) => st.todos` | `'/a/b'` |
|
|
763
|
+
| `bind('a', { event: ['target'] })`, `action(fn, { event: ['files'] })` | `an event binding cannot request 'target' — APP-FORMAT §3.1 excludes target, files, touch lists and every other host-object-valued field by construction, because $event MUST survive JSON.stringify, the same invariant as state. Register an extractor under a name of its own (§5.4's worked example maps event.target.files to opaque string tokens under 'fileTokens') and request that.` | register an extractor, request its name |
|
|
764
|
+
| a view binding an action `actions` does not declare | `the view binds the action 'nope', which "actions" does not declare — the runtime drops such a dispatch and reports JA2001 (APP-FORMAT §4), so the user's click does nothing; the declared actions are 'real'` | declare it, or fix the spelling |
|
|
765
|
+
| `sub('r', { with: 1, withQuery: fn })` | `sub() carries both 'with' and 'withQuery' — one entry, one props source: 'with' is verbatim data and never restarts, 'withQuery' derives the props from the state and makes the entry dynamic (APP-FORMAT §5.3, the runtime's JA0008)` | one of the two |
|
|
766
|
+
| `sub('r', { with: 1, for: fn })` | `sub() carries 'with' beside 'for' — a fan-out's props come from 'withQuery', or default to the item itself (APP-FORMAT §5.3, the runtime's JA0008)` | drop `with`, or drop `for` |
|
|
767
|
+
| `sub('r', { key: fn })` | `sub() carries 'key' without 'withQuery' or 'for' — a static subscription never restarts, so it has no restart key (APP-FORMAT §5.3, the runtime's JA0008)` | add `withQuery` or `for` |
|
|
768
|
+
|
|
769
|
+
**A patch path that is not a chain of member reads and subscripts.** No
|
|
770
|
+
JSON Pointer can be written for one, and the pen will not guess where an
|
|
771
|
+
arbitrary expression lands in the state. The line between the two is
|
|
772
|
+
exactly this: a chain of `.member` reads, `get(name)` reads and `at(i)`
|
|
773
|
+
subscripts lowers — `at()` with a COMPUTED index included, which is the
|
|
774
|
+
`$concat` of [§2.3](#23-the-seven-patch-operations) and works. Anything
|
|
775
|
+
that calls an OPERATOR does not: `all()`, `count()`, `filter()`,
|
|
776
|
+
arithmetic. The two spellings side by side:
|
|
777
|
+
|
|
778
|
+
```js
|
|
779
|
+
replace((c, y) => c.todos.at(y.payload.i).done, true) // { "$concat": ["/todos/", "$payload.i", "/done"] }
|
|
780
|
+
replace((c) => c.todos.all().count(), 1) // JL0102 — no pointer names a count
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
Where a location genuinely cannot be reached from the state shape, pass
|
|
784
|
+
the pointer as a string; that door is always open, and the only check on
|
|
785
|
+
it is that it starts with `/`.
|
|
786
|
+
|
|
787
|
+
**An `$event` field APP-FORMAT §3.1 excludes by construction.** A reader
|
|
788
|
+
will reach for `event.target` on their first afternoon, and the reason
|
|
789
|
+
they cannot have it is not a policy: **`$event` MUST survive
|
|
790
|
+
`JSON.stringify`.** A DOM node in the event slice would break the
|
|
791
|
+
invariant state itself keeps — it could not be serialized, replayed,
|
|
792
|
+
sent to a devtool or compared between transactions — so §3.1 excludes
|
|
793
|
+
`target` and thirteen siblings BY CONSTRUCTION, not by omission, and the
|
|
794
|
+
pen refuses the request rather than emitting a document the loop would
|
|
795
|
+
bind `null` for. The way through is §5.4's own worked example: a host
|
|
796
|
+
registers an extractor under a name of its own, which maps the host
|
|
797
|
+
object to a JSON value at the boundary, and the binding requests THAT
|
|
798
|
+
name.
|
|
799
|
+
|
|
800
|
+
```js
|
|
801
|
+
bind('pickFiles', { event: ['files'] }) // JL0102
|
|
802
|
+
bind('pickFiles', { event: ['fileTokens'] }) // fine — the host's extractor answers it
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
An extractor registered under one of the fourteen excluded names would
|
|
806
|
+
shadow the exclusion the format exists to state, so the pen refuses
|
|
807
|
+
those names whatever the registry holds; every OTHER name is allowed,
|
|
808
|
+
because an extractor wins over the built-in allow-list (§3.1's
|
|
809
|
+
precedence order) and the pen cannot see the host's registry.
|
|
810
|
+
|
|
811
|
+
**An initial state no `default()` describes.** The rule §1.2 states is
|
|
812
|
+
what makes this a refusal rather than a shrug: the state a default
|
|
813
|
+
describes IS the state the document carries, so a required member with
|
|
814
|
+
no default leaves the document with no value for it, and the app fails
|
|
815
|
+
its own `validateState` on the boot transaction — `JA0007`, fatal,
|
|
816
|
+
after everything acquired is rolled back. The pen sees it at build time
|
|
817
|
+
and names the pointer. An OPTIONAL member with no default is fine and
|
|
818
|
+
simply does not appear.
|
|
819
|
+
|
|
820
|
+
**A view binding an action `actions` does not declare.** APP-FORMAT §4
|
|
821
|
+
leaves an unknown name to run time — `JA2001` per dispatch, and the
|
|
822
|
+
user's click does nothing at all, silently. `defineApp()` sees the whole
|
|
823
|
+
document at once and refuses it, naming the declared alternatives so a
|
|
824
|
+
typo is one glance from fixed. [§3.7](#37-a-view-binding-an-action-and-the-refusal-when-it-is-undeclared)
|
|
825
|
+
shows both binding forms it reads and the one it cannot.
|
|
826
|
+
|
|
827
|
+
### 4.4 `JL0104` — the closed worlds
|
|
828
|
+
|
|
829
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
830
|
+
|---|---|---|
|
|
831
|
+
| `action((st, x) => transition({ state: x.clock }))` | `an action() rule cannot bind 'clock' — its query evaluates with exactly 2 externals, 'event' and 'payload'; anything else has nothing to bind to — the whole state is the first argument; §3.1 binds $event and $payload and nothing else, and a host value reaches an action through a binding's payload or an event-field extractor` | `x.event`, `x.payload`, or the state |
|
|
832
|
+
| `sub('r', { withQuery: (st, x) => x.item })` | `a sub() withQuery rule cannot bind 'item' — its query evaluates with no externals at all; anything else has nothing to bind to — a subscription's withQuery is compiled closed-world against the state alone; $item exists only under a "for" declaration (APP-FORMAT §5.3)` | add `for`, or read the state |
|
|
833
|
+
| `sub('r', { when: (st, x) => x.item, for: fn })` | `a sub() when rule cannot bind 'item' — … — a subscription's when is compiled closed-world against the state alone; $item exists only under a "for" declaration (APP-FORMAT §5.3)` | `when` sees the state alone |
|
|
834
|
+
|
|
835
|
+
Three scopes, and the pen mirrors each exactly:
|
|
836
|
+
|
|
837
|
+
| Where | `$` | Externals |
|
|
838
|
+
|---|---|---|
|
|
839
|
+
| an action, and a patch path inside it | the whole state | `$event`, `$payload` |
|
|
840
|
+
| a subscription's `when` and `for` | the whole state | none |
|
|
841
|
+
| a subscription's `withQuery` and `key` | the whole state | `$item`, and only under a `for` |
|
|
842
|
+
|
|
843
|
+
The mirror is the point. A document the pen writes cannot be the
|
|
844
|
+
`JA0008` a free variable would be at `createApp` time, because the pen
|
|
845
|
+
raised `JL0104` at the line that wrote it — where the fix has a name and
|
|
846
|
+
a stack — rather than letting an unbound external reach the loop's
|
|
847
|
+
compiler.
|
|
848
|
+
|
|
849
|
+
Two of the three messages are the shared capture's, so they open by
|
|
850
|
+
counting the EXTERNALS and then name the whole vocabulary in the advice
|
|
851
|
+
the pen appends. "Exactly 2 externals" for an action means `$event` and
|
|
852
|
+
`$payload`; the state is the first argument and is never an external.
|
|
853
|
+
|
|
854
|
+
The app pen writes no keyword of its own onto a schema, so the other
|
|
855
|
+
half of `JL0104` — a pen-owned keyword written through `meta()` — is
|
|
856
|
+
reachable here only through the schema pen that types the state
|
|
857
|
+
([SCHEMA-PEN.md §4.4](SCHEMA-PEN.md#44-jl0104--the-keyword-and-the-external)).
|
|
858
|
+
|
|
859
|
+
## 5. The types
|
|
860
|
+
|
|
861
|
+
```ts
|
|
862
|
+
import { createApp } from '@jarenjs/app';
|
|
863
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
864
|
+
import { action, append, bind, defineApp, effect, replace, sub, transition } from '@jarenjs/linq/app';
|
|
865
|
+
import type {
|
|
866
|
+
ActionScope, ActionsOf, AppDocument, AppResult, Binding, EffectDeclaration,
|
|
867
|
+
EventSlice, FanScope, PatchOp, PatchPath, StateOf, SubDeclaration, Transition,
|
|
868
|
+
} from '@jarenjs/linq/app';
|
|
869
|
+
import { rule } from '@jarenjs/linq/jslt';
|
|
870
|
+
import * as s from '@jarenjs/linq/schema';
|
|
871
|
+
import type { Expr } from '@jarenjs/linq';
|
|
872
|
+
import type { Infer } from '@jarenjs/linq/schema';
|
|
873
|
+
|
|
874
|
+
const State = s.object({
|
|
875
|
+
todos: s.array(s.object({ text: s.string(), done: s.boolean().default(false) })).default([]),
|
|
876
|
+
draft: s.string().default(''),
|
|
877
|
+
});
|
|
878
|
+
type State = Infer<typeof State>;
|
|
879
|
+
type Action = 'todo/add' | 'todo/toggleAt';
|
|
880
|
+
|
|
881
|
+
const Payload = s.object({ text: s.string() });
|
|
882
|
+
|
|
883
|
+
const todo = defineApp({
|
|
884
|
+
state: State,
|
|
885
|
+
view: [rule('$', (v: Expr<State>) => ['main', {},
|
|
886
|
+
['button', { on: { click: bind<Action>('todo/add', { payload: { text: v.draft } }) } }, 'add'],
|
|
887
|
+
])],
|
|
888
|
+
actions: {
|
|
889
|
+
// the state is typed by ANNOTATION; the payload by its own declaration
|
|
890
|
+
'todo/add': action((st: Expr<State>, x) => transition({
|
|
891
|
+
patch: [append((c: Expr<State>) => c.todos, { text: x.payload.text, done: false })],
|
|
892
|
+
}), { payload: Payload }),
|
|
893
|
+
'todo/toggleAt': action((st: Expr<State>, x) => transition({
|
|
894
|
+
patch: [replace((c: Expr<State>, y: ActionScope<{ i: number }>) =>
|
|
895
|
+
c.todos.at(y.payload.i).done, true)],
|
|
896
|
+
})),
|
|
897
|
+
},
|
|
898
|
+
subs: [sub('interval', { with: { ms: 1000 }, when: (st: Expr<State>) => st.todos.all().count().gt(0) })],
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
type Shape = StateOf<typeof todo>; // the state the document describes
|
|
902
|
+
type Names = ActionsOf<typeof todo>; // 'todo/add' | 'todo/toggleAt'
|
|
903
|
+
|
|
904
|
+
const { document, stateSchema } = todo;
|
|
905
|
+
// no narrow and no cast: the `state: <builder>` overload can never
|
|
906
|
+
// answer null, and AppResult carries that in its third phantom (§5.4)
|
|
907
|
+
createApp(document, { node, validateState: new JarenValidator().compile(stateSchema) });
|
|
908
|
+
|
|
909
|
+
bind<Names>('todo/add'); // fine
|
|
910
|
+
bind<Names>('nope'); // does not compile — and JL0102 at defineApp()
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
### 5.1 The two phantoms, and what reads them
|
|
914
|
+
|
|
915
|
+
`AppDocument<State, Actions>` carries two phantoms: the state SHAPE, and
|
|
916
|
+
the declared action NAMES as a literal union. Both are read off the
|
|
917
|
+
declarations themselves — `defineApp`'s `A extends Record<string,
|
|
918
|
+
ActionDeclaration<any>>` is `const`, so the keys stay literal — and both
|
|
919
|
+
come back out:
|
|
920
|
+
|
|
921
|
+
| Type | Reads | What it is for |
|
|
922
|
+
|---|---|---|
|
|
923
|
+
| `StateOf<A>` | the state shape | what a host reading `app.getState()` needs |
|
|
924
|
+
| `ActionsOf<A>` | the literal action names | what a `bind<Names>()` is checked against |
|
|
925
|
+
|
|
926
|
+
`AppResult<State, Actions>` is the pair `defineApp()` answers,
|
|
927
|
+
`{ document, stateSchema }`. Both `StateOf<>` and `ActionsOf<>` accept
|
|
928
|
+
either the result or the document, so a consumer that stores only the
|
|
929
|
+
document loses nothing.
|
|
930
|
+
|
|
931
|
+
### 5.2 The honest limits, both of them TypeScript's own
|
|
932
|
+
|
|
933
|
+
A function's type arguments are all-or-none, and a sibling member's
|
|
934
|
+
inferred type cannot contextually type a callback beside it. Everything
|
|
935
|
+
awkward about this pen's types follows from those two:
|
|
936
|
+
|
|
937
|
+
- **An action's state is typed by ANNOTATION** — `action((s: Expr<State>,
|
|
938
|
+
x) => …)`, and a patch path the same — because `action()` is evaluated
|
|
939
|
+
before `defineApp()` sees the `state` builder. What `defineApp({ state
|
|
940
|
+
})` types is the DOCUMENT (`StateOf<>`). `x.payload` needs NO
|
|
941
|
+
annotation: `action(fn, { payload })` declares it on the same call, and
|
|
942
|
+
`{ event: [...] as const }` adds the requested members to `x.event`
|
|
943
|
+
beside APP-FORMAT §3.1's default slice — which is what `EventSlice<Fields>`
|
|
944
|
+
spells, four known members plus one `unknown` per requested field.
|
|
945
|
+
- **`bind()`'s action name is checked by annotating the call** —
|
|
946
|
+
`bind<Action>('todo/add')`, with `Action` declared or read back through
|
|
947
|
+
`ActionsOf<>`. `defineApp()` checks the other direction at run time
|
|
948
|
+
over the whole view, which is the half a type cannot reach: by then a
|
|
949
|
+
view is a compiled stylesheet.
|
|
950
|
+
- **A view body's value is the honest top until it is annotated**
|
|
951
|
+
(`rule('$', (v: Expr<State>) => …)`), exactly as the JSLT pen's body
|
|
952
|
+
is ([JSLT-PEN.md §5](JSLT-PEN.md#5-the-types)).
|
|
953
|
+
|
|
954
|
+
`PatchPath<State, Payload>` is the union of the lambda and the pointer
|
|
955
|
+
string, and its lambda sees the same scope the action does — so a
|
|
956
|
+
computed index may read `$payload`, annotated the same way
|
|
957
|
+
(`(c: Expr<State>, y: ActionScope<P>) => …`). `FanScope<Item>` is the
|
|
958
|
+
one-member scope a subscription's `withQuery`/`key` gets under a `for`.
|
|
959
|
+
|
|
960
|
+
### 5.3 A member named like a chain method captures as the method
|
|
961
|
+
|
|
962
|
+
The trap a reader meets in their first hour, and it is not the pen's:
|
|
963
|
+
the capture proxy answers a chain METHOD before it answers a member of
|
|
964
|
+
the same name, so a state whose member shares a name with one of them
|
|
965
|
+
hands the lambda a function instead of an expression. The set is
|
|
966
|
+
[QUERY-PEN.md §4](QUERY-PEN.md#4-the-mapping-table)'s "expression
|
|
967
|
+
methods" row, and a state schema meets it more often than a reader
|
|
968
|
+
expects — `count`, `sum`, `min`, `max`, `avg`, `length`, `at`, `all`,
|
|
969
|
+
`get`, `exists`, `contains`, `replace` and `add` are all ordinary member
|
|
970
|
+
names and all shadowed. Names that are NOT shadowed include the sequence
|
|
971
|
+
terminals: `first`, `map` and `filter` are chain methods on a SEQUENCE,
|
|
972
|
+
and an action's state is an expression, so those read as members.
|
|
973
|
+
|
|
974
|
+
```js
|
|
975
|
+
const app = defineApp({
|
|
976
|
+
state: s.object({ count: s.integer().default(0) }),
|
|
977
|
+
view: [rule('$', (v) => ['output', {}, v.count])],
|
|
978
|
+
// …
|
|
979
|
+
});
|
|
980
|
+
// JL0005: a captured expression cannot embed a function value
|
|
981
|
+
```
|
|
982
|
+
|
|
983
|
+
The same spelling in an action reads the same way —
|
|
984
|
+
`action((st) => transition({ state: st.count }))` is the same `JL0005`
|
|
985
|
+
— and the message names the symptom rather than the cause, because by
|
|
986
|
+
the time the capture sees a function it no longer knows the name that
|
|
987
|
+
produced it. The escape is the one `get()` exists for, and it lowers to
|
|
988
|
+
exactly what a member read would have:
|
|
989
|
+
|
|
990
|
+
```js
|
|
991
|
+
rule('$', (v) => ['output', {}, v.get('count')]) // "$['count']"
|
|
992
|
+
replace((c) => c.get('count'), 1) // { "op": "replace", "path": "/count" }
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
Naming the member something else is the other fix and usually the better
|
|
996
|
+
one. Nothing in the state's SCHEMA is affected — the member is a member
|
|
997
|
+
either way, and only the lambda that reads it has to spell it
|
|
998
|
+
differently.
|
|
999
|
+
|
|
1000
|
+
### 5.4 The schema slot is typed by the overload that filled it
|
|
1001
|
+
|
|
1002
|
+
`defineApp()` answers `null` for `stateSchema` when the state is a plain
|
|
1003
|
+
JSON value with no `schema` beside it, and a document for the two
|
|
1004
|
+
overloads that were handed a builder. `AppResult` carries that as its
|
|
1005
|
+
THIRD phantom rather than one union across all three, so the presence of
|
|
1006
|
+
a schema survives into the type:
|
|
1007
|
+
|
|
1008
|
+
| The call | `stateSchema` |
|
|
1009
|
+
|---|---|
|
|
1010
|
+
| `defineApp({ state: <builder>, … })` | `JsonSchema \| boolean` |
|
|
1011
|
+
| `defineApp({ state: <json>, schema: <builder>, … })` | `JsonSchema \| boolean` |
|
|
1012
|
+
| `defineApp({ state: <json>, … })` | `null` |
|
|
1013
|
+
|
|
1014
|
+
That is what lets the one line every consumer writes compile with no
|
|
1015
|
+
narrow and no cast — and it is exactly the line that does not compile
|
|
1016
|
+
when the slot is one union, because `JarenValidator.compile()` takes
|
|
1017
|
+
`boolean | JSONSchemaLike` and not `null`:
|
|
1018
|
+
|
|
1019
|
+
```ts
|
|
1020
|
+
const { stateSchema } = defineApp({ state: s.object({ n: s.integer().default(0) }), view: [] });
|
|
1021
|
+
new JarenValidator().compile(stateSchema); // fine
|
|
1022
|
+
|
|
1023
|
+
const plain = defineApp({ state: { n: 1 }, view: [] });
|
|
1024
|
+
new JarenValidator().compile(plain.stateSchema); // does not compile: there is no schema
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
The third parameter defaults to the whole union, so `AppResult<State,
|
|
1028
|
+
Actions>` still names any result and no consumer annotation has to spell
|
|
1029
|
+
it. `test/consumer/linq-app.ts` pins all four lines, the negative
|
|
1030
|
+
included.
|
|
1031
|
+
|
|
1032
|
+
### 5.5 What the pins hold
|
|
1033
|
+
|
|
1034
|
+
`test/consumer/linq-app.ts` carries this pen's compile-level half beside
|
|
1035
|
+
the forms pen's — one file, because they are the two pens a form-shaped
|
|
1036
|
+
application uses together. Compiled by `npm run test:types`, it holds:
|
|
1037
|
+
|
|
1038
|
+
- `StateOf<typeof todo>` EQUAL (not merely assignable) to `Infer<typeof
|
|
1039
|
+
State>`, and `ActionsOf<typeof todo>` equal to the declared name union
|
|
1040
|
+
— both by the strict `Equals<>` test, in both directions;
|
|
1041
|
+
- `todo.document` assignable to `AppDocument<State, Action>`, and a
|
|
1042
|
+
`@ts-expect-error` on `todo.document.stateSchema`: the two are two
|
|
1043
|
+
members and the document is not one of them;
|
|
1044
|
+
- a payload member the declaration does not carry, a state member the
|
|
1045
|
+
schema does not carry, and an `$event` field the binding did not
|
|
1046
|
+
request — three `@ts-expect-error`s, each the type half of a refusal
|
|
1047
|
+
§4 states at run time;
|
|
1048
|
+
- `bind<Action>('nope')` and `bind<ActionsOf<typeof todo>>('nope')`,
|
|
1049
|
+
both `@ts-expect-error`;
|
|
1050
|
+
- `transition({ nope: 1 })` and `sub('interval', { with: () => 1 })`,
|
|
1051
|
+
the two closed vocabularies;
|
|
1052
|
+
- `PatchOp`, `EffectDeclaration<'save'>` and `SubDeclaration<'interval'>`
|
|
1053
|
+
as the concrete types the three helpers answer, with
|
|
1054
|
+
`effect('save').run` pinned to the literal `'save'`.
|
|
1055
|
+
|
|
1056
|
+
## 6. What it cannot spell
|
|
1057
|
+
|
|
1058
|
+
Every construct the app pen refuses as unspellable is `JL0102`, and §4.3
|
|
1059
|
+
carries the four conditions with the spelling that works for each: a
|
|
1060
|
+
patch path that is not a chain of member reads and subscripts, an
|
|
1061
|
+
`$event` field APP-FORMAT §3.1 excludes by construction, an initial
|
|
1062
|
+
state no `default()` describes, and a view binding an action `actions`
|
|
1063
|
+
does not declare. Those are limits of the FORMAT reached through the
|
|
1064
|
+
pen, and each has a way through.
|
|
1065
|
+
|
|
1066
|
+
One limit is the format's own and has no way through today: **an action
|
|
1067
|
+
document cannot express AWAITING.** A transition is a pure function of
|
|
1068
|
+
the pre-transition state to a next state plus a list of effects; an
|
|
1069
|
+
effect runs after the transition settles and reaches back only by
|
|
1070
|
+
dispatching another action. That is deliberate — it is what makes a
|
|
1071
|
+
transition replayable and a document serializable — but it means a
|
|
1072
|
+
sequence like "call this, then patch with the answer, then call that" is
|
|
1073
|
+
written as a chain of actions and effect callbacks rather than as one
|
|
1074
|
+
document.
|
|
1075
|
+
|
|
1076
|
+
The convention for it exists and is the host's, not the format's:
|
|
1077
|
+
`createTaskEffect` and the async-task pattern of
|
|
1078
|
+
[TASKS.md](../../app/docs/TASKS.md) give the sequence a key, a
|
|
1079
|
+
concurrency mode and a structured failure, and APP-FORMAT §9 is the
|
|
1080
|
+
normative half. What is open is expressing the awaiting IN the action
|
|
1081
|
+
document, which is an entry in `docs/ROADMAP.md` under `@jarenjs/app`;
|
|
1082
|
+
this pen writes whatever that grammar grows, because it has no opinion
|
|
1083
|
+
of its own about it.
|
|
1084
|
+
|
|
1085
|
+
Two smaller things the pen deliberately does not do, so a reader does
|
|
1086
|
+
not look for them:
|
|
1087
|
+
|
|
1088
|
+
- **It writes no state schema into the document.** The format has no
|
|
1089
|
+
slot for one, and inventing an `x-` member for it would make the pen's
|
|
1090
|
+
documents a dialect. `stateSchema` is answered beside the document and
|
|
1091
|
+
goes to `options.validateState`.
|
|
1092
|
+
- **It judges nothing the loop must judge anyway.**
|
|
1093
|
+
[§2.6](#26-what-the-pen-does-not-judge) is the list, and the reason is
|
|
1094
|
+
the binder's rule 1: a pen refuses only what it can see, and inventing
|
|
1095
|
+
a check the engine does not make would be a second, weaker validator.
|
|
1096
|
+
|
|
1097
|
+
### 6.1 When not to reach for this pen
|
|
1098
|
+
|
|
1099
|
+
- **The app document is data.** One read from a file, authored in the
|
|
1100
|
+
studio, projected from a machine by `fsmToApp`
|
|
1101
|
+
([FLOW-PEN.md](FLOW-PEN.md)), or written by a model is a value;
|
|
1102
|
+
`createApp` takes it directly.
|
|
1103
|
+
- **You are writing a component, not an application.** An app document is
|
|
1104
|
+
one state, one view and one action map — the unit `createApp` runs. A
|
|
1105
|
+
reusable widget is a view function and a set of props, and it lives in
|
|
1106
|
+
`@jarenjs/view` where it can take children and callbacks; an app
|
|
1107
|
+
document has no slot for either.
|
|
1108
|
+
- **The logic is mostly awaiting.** §6 above says it plainly: a
|
|
1109
|
+
transition is pure and an effect reaches back only by dispatching. An
|
|
1110
|
+
application whose interesting part is a sequence of awaited calls is
|
|
1111
|
+
mostly host code with an app document attached, and the reading order
|
|
1112
|
+
should follow — write the tasks, then the document that dispatches
|
|
1113
|
+
them.
|
|
1114
|
+
- **The state does not fit in a document.** Every value in the state is
|
|
1115
|
+
JSON: no DOM nodes, no class instances, no functions, no handles. A
|
|
1116
|
+
canvas, a media element or a socket lives in the host and reaches the
|
|
1117
|
+
document only as the data it produces.
|
|
1118
|
+
- **You would be fighting the patch.** A state shape that needs deep
|
|
1119
|
+
rewrites on every event is a shape that wants normalizing before it
|
|
1120
|
+
wants a pen. §3.1 is the first place this shows: `add` at a path that
|
|
1121
|
+
names an array replaces it, and a reader who wanted `append` had a
|
|
1122
|
+
shape question rather than a spelling one.
|
|
1123
|
+
|
|
1124
|
+
## 7. Cost
|
|
1125
|
+
|
|
1126
|
+
`@jarenjs/linq/app` builds to **<!--fact:bundle.app-->47,444<!--/fact--> bytes** as a minified,
|
|
1127
|
+
tree-shaken ESM bundle — the figure `scripts/check-tree-shaking.js`
|
|
1128
|
+
measures and `npm run test:tree-shaking` reports, published rounded
|
|
1129
|
+
beside the other nine subpath prices in
|
|
1130
|
+
[docs/CONSUMING.md](../../../docs/CONSUMING.md). It carries the schema
|
|
1131
|
+
pen and the JSLT pen (state, and views), and no chain module, no
|
|
1132
|
+
`@jarenjs/app` or `@jarenjs/view` byte and no other pen.
|
|
1133
|
+
|
|
1134
|
+
It is the second-largest pen bundle after the client, and the two pens
|
|
1135
|
+
it carries are most of it. The three figures the same probe measures,
|
|
1136
|
+
side by side: `@jarenjs/linq/schema` <!--fact:bundle.schema-->33,156<!--/fact--> bytes,
|
|
1137
|
+
`@jarenjs/linq/jslt` <!--fact:bundle.jslt-->19,856<!--/fact-->, `@jarenjs/linq/app` <!--fact:bundle.app-->47,444<!--/fact-->. The subpath sums do not add — all
|
|
1138
|
+
three carry the capture, the expression lowering and the JSON boundary,
|
|
1139
|
+
which each bundle counts once — so what the app pen costs a consumer who
|
|
1140
|
+
already imports the schema pen is the difference the numbers do state:
|
|
1141
|
+
**14,435 bytes**, the JSLT pen's non-shared half plus this pen's own
|
|
1142
|
+
`defineApp`, action capture, seven patch operations, pointer lowering,
|
|
1143
|
+
`bind()`, `sub()` and their refusal messages.
|