@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/FLOW-PEN.md
ADDED
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
# The Jaren flow pen
|
|
2
|
+
|
|
3
|
+
> `./flow` — `jaren-fsm` 0.1 machines and `jaren-dag` 0.1 dataflows,
|
|
4
|
+
> every query-valued member captured. **Read it when** you are declaring
|
|
5
|
+
> a state machine or a dependency graph of tasks
|
|
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
|
+
You have a process to describe — a document that moves between states as
|
|
18
|
+
events arrive, or a pipeline that takes one value and hands it through
|
|
19
|
+
named steps — and you want it as data, so a diagram, a runner and a test
|
|
20
|
+
can all read the same thing. Writing that data by hand means typing state
|
|
21
|
+
ids into a transition table and JSONPath strings into guards, with
|
|
22
|
+
nothing checking that either exists. This pen makes both a function call:
|
|
23
|
+
the ids are literal types, and every query-valued member is a callback it
|
|
24
|
+
records.
|
|
25
|
+
|
|
26
|
+
**This subpath writes two different documents, compiled by two different
|
|
27
|
+
engines.** `defineFsm()` writes a `jaren-fsm` 0.1 machine — control
|
|
28
|
+
states and a transition table — and `defineDag()` writes a `jaren-dag`
|
|
29
|
+
0.1 dataflow — named nodes wired by edges. They share a package, a
|
|
30
|
+
subpath and a capture, and nothing else: a reader who thinks the two
|
|
31
|
+
functions write the same document will misread everything after this
|
|
32
|
+
paragraph.
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import { defineFsm, state, on, effect } from '@jarenjs/linq/flow';
|
|
36
|
+
import { defineDag, input, constant, query, jslt, task, output, edge, typedTasks } from '@jarenjs/linq/flow';
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| | the machine | the dataflow |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| document | `{ $fsm: '0.1', initial, states, transitions }` | `{ $dag: '0.1', nodes, edges }` |
|
|
42
|
+
| grammar | `jaren-fsm` (`packages/flow/schemas/jaren-fsm.schema.json`) | `jaren-dag` (`packages/flow/schemas/jaren-dag.schema.json`) |
|
|
43
|
+
| format | [FLOW-FORMAT](../../flow/docs/FLOW-FORMAT.md) §2 | FLOW-FORMAT §6 |
|
|
44
|
+
| engine | `compileFsm` — and `fsmToApp`, which projects a machine into app documents | `compileDag` |
|
|
45
|
+
| shape of the work | synchronous, pure: `(state, event, options)` → a result and effect DESCRIPTORS the host runs | asynchronous: one input in, one value out, tasks resolved from a registry |
|
|
46
|
+
| the pen's vocabulary | `defineFsm`, `state`, `on` (`.when`, `.to`, `.effects`), `effect` | `defineDag`, `input`, `output`, `constant`, `query`, `jslt`, `task` (`.checkpoint`), `edge`, `typedTasks` |
|
|
47
|
+
|
|
48
|
+
Every query-valued member of either document is a CALLBACK captured over
|
|
49
|
+
the scope the engine evaluates it in, never a path typed as a string: a
|
|
50
|
+
guard and an effect's `with` over FLOW-FORMAT §3's step scope, a `query`
|
|
51
|
+
node's document, a task's `with` and an edge's `select` over §6.1's input
|
|
52
|
+
scope. That is also why the one trap the format names itself is refused
|
|
53
|
+
here — a guard given as a plain STRING is `JL0102`, because §3 makes a
|
|
54
|
+
non-`$` literal vacuously TRUE so that a diagram's display annotation can
|
|
55
|
+
never change execution (§4.2).
|
|
56
|
+
|
|
57
|
+
State ids, event names and node ids are literal types, so a transition
|
|
58
|
+
into an undeclared state and an edge on an undeclared node are compile
|
|
59
|
+
errors; at runtime they are `JL0102` naming the id, before the compiler's
|
|
60
|
+
`JF0004`/`JF0006`/`JF0013`. A machine written with string states and no
|
|
61
|
+
guards is also a valid `jaren-workflow` document — the projection
|
|
62
|
+
contract FLOW-FORMAT §1 calls the format's subset — so the pen's machines
|
|
63
|
+
and the mermaid projection's meet where the format says they do (§6.4).
|
|
64
|
+
|
|
65
|
+
The pen imports nothing of `@jarenjs/flow`: the compilers stay the only
|
|
66
|
+
judge of what the documents mean, and §7's tree-shaking probe holds it.
|
|
67
|
+
|
|
68
|
+
**The running example.** §3 is one editorial workflow, told twice because
|
|
69
|
+
the subpath writes two documents. The machine half (§3.1–§3.3) is an
|
|
70
|
+
article moving from draft to published: the transition table, then the
|
|
71
|
+
same table with effects, then the guard that reads a score against a
|
|
72
|
+
threshold. The dataflow half (§3.4–§3.7) is the pipeline around it: draft
|
|
73
|
+
and critique, filter the submissions that are long enough, render the
|
|
74
|
+
accepted ones as a view, and answer a question about them through a task
|
|
75
|
+
registry. §5 reads the types back off the same two documents.
|
|
76
|
+
|
|
77
|
+
## 2. The mapping table
|
|
78
|
+
|
|
79
|
+
Thirteen exported names and sixteen rows: the four builder methods
|
|
80
|
+
(`.when`, `.to`, `.effects`, `.checkpoint`) earn rows of their own, and
|
|
81
|
+
`input()` and `output()` share one because they differ in nothing but the
|
|
82
|
+
word they write. The completeness gate in `test/linq/pen-docs.test.js`
|
|
83
|
+
asserts that every exported callable name appears somewhere in this
|
|
84
|
+
section.
|
|
85
|
+
|
|
86
|
+
| Method | Emits | Type reading | Status |
|
|
87
|
+
|---|---|---|---|
|
|
88
|
+
| `defineFsm({ initial, states, transitions, context? })` | `{ $fsm: '0.1', initial, states, transitions }` | `Fsm<States, Events, Context>`; `StatesOf<>`, `EventsOf<>`, `ContextOf<>` read it | native; a member the pen does not know, a missing `initial` (pass `null`), a non-array `states`/`transitions`, an entry that is not a declaration, a transition that never named its target, a `context` that is not a builder `JL0101`; an undeclared state id `JL0102` |
|
|
89
|
+
| `state(id, { entry?, exit?, final? })` | `{ id, entry?, exit?, final? }`; a bare string in `states` stays §2's shorthand | `StateDeclaration<Id>` — `Id` is a literal | native; an empty id, another member, a non-boolean `final`, a non-array or non-`effect()` entry/exit `JL0101` |
|
|
90
|
+
| `on(from, event?, { payload? })` | one entry of `transitions`, `{ from, event?, guard?, to, effects? }` in §2's order; a wildcard writes no `event` | `Transition<From, To, Event, Payload>` — a wildcard names no event, so it adds nothing to `EventsOf<>` | native; an empty `from`, a non-string event, another option, a `payload` that is not a builder `JL0101`; an undeclared `from`/`to` `JL0102` |
|
|
91
|
+
| `.when(fn)` / `.when(document)` | the transition's `guard` (§3) | the scope is `Scope<unknown, Payload>`; annotate for `context` | native; a plain STRING `JL0102` (§3's vacuous-guard rule); a non-JSON document `JL0101`; any external `JL0104` |
|
|
92
|
+
| `.to(state)` | the transition's `to` | `Transition<From, To, …>` — `To` is a literal | native; an empty id `JL0101`; an undeclared id `JL0102` at `defineFsm()` |
|
|
93
|
+
| `.effects([...])`, `state(…, { entry, exit })` | the effects lists §4 fires in exit → transition → entry order | `EffectDeclaration[]` | native; a non-array, or an entry that is not `effect()`, `JL0101` |
|
|
94
|
+
| `effect(run, with?)` | `{ run, with? }` | `EffectDeclaration<Run>`; the scope is the honest top until annotated | native; an empty `run`, a non-JSON `with` `JL0101`; any external `JL0104` |
|
|
95
|
+
| `defineDag({ nodes, edges })` | `{ $dag: '0.1', nodes, edges }` | `Dag<Ids, Tasks>`; `NodesOf<>`, `TasksOf<>` read it | native; a member the pen does not know, no node, a `nodes` map whose prototype a `__proto__:` literal replaced, a value that is not a node or edge declaration `JL0101`; an edge on an undeclared id `JL0102` |
|
|
96
|
+
| `input()` / `output()` | `{ kind: 'input' }` / `{ kind: 'output' }` | `NodeDeclaration<'input'>` / `<'output'>` | native |
|
|
97
|
+
| `constant(value)` | `{ kind: 'const', value }` | `NodeDeclaration<'const'>` | native; `undefined`, or a value that is not JSON, `JL0101` |
|
|
98
|
+
| `query(fn \| document)` | `{ kind: 'query', query }` | `NodeDeclaration<'query'>` | native; nothing passed, or a document that is not JSON, `JL0101`; any external `JL0104` |
|
|
99
|
+
| `jslt(stylesheet)` | `{ kind: 'jslt', stylesheet }` — the JSLT pen's document ([JSLT-PEN.md](JSLT-PEN.md)), or one by hand | `NodeDeclaration<'jslt'>` | native; nothing passed, or a value that is not JSON, `JL0101` |
|
|
100
|
+
| `task(run, with?)` | `{ kind: 'task', run, with? }` | `NodeDeclaration<'task', Run>` — `Run` is a literal | native; an empty `run` `JL0101`; any external in `with` `JL0104` |
|
|
101
|
+
| `.checkpoint()` | `checkpoint: true`, written last (§7.6) | a new declaration; the one it came from is unchanged | native |
|
|
102
|
+
| `edge(from, to, { port?, select? })` | `{ from, to, port?, select? }` | `EdgeDeclaration<From, To>` | native; an empty end, an empty `port`, another member `JL0101`; any external in `select` `JL0104` |
|
|
103
|
+
| `typedTasks(graph, tasks)` | — (identity) | the registry `compileDag` resolves must carry one handler per declared task name | native; a missing or misspelled name does not compile |
|
|
104
|
+
|
|
105
|
+
Three rules the table implies, spelled out:
|
|
106
|
+
|
|
107
|
+
- **The node kinds are a closed vocabulary and the pen mirrors it
|
|
108
|
+
exactly.** FLOW-FORMAT §6 fixes six kinds, and this pen has one
|
|
109
|
+
function per kind — so a kind the format does not have cannot be
|
|
110
|
+
spelled, and a kind it adds later needs a pen function before it can
|
|
111
|
+
be. `defineDag` refuses anything in `nodes` that is not one of the six
|
|
112
|
+
(`JL0101` naming all six), which is why a hand-written
|
|
113
|
+
`{ kind: 'input' }` is refused where the equivalent `input()` is taken.
|
|
114
|
+
- **`payload` and `context` are TYPES; nothing is emitted for them.** The
|
|
115
|
+
format carries no schema for either — a machine's data lives in the
|
|
116
|
+
host (§3) — so `on(from, event, { payload })` and
|
|
117
|
+
`defineFsm({ context })` type the guards and the host's `step()` call
|
|
118
|
+
and write no member. The refusal for a non-builder says so in as many
|
|
119
|
+
words.
|
|
120
|
+
- **What the pen does NOT judge is the compiler's.** Duplicate state ids
|
|
121
|
+
(`JF0003`), a guard's or stylesheet's own operators (`JF0007`,
|
|
122
|
+
`JF0014`), the dag wiring rules (`JF0015`), acyclicity (`JF0016`), the
|
|
123
|
+
exactly-one-output rule (`JF0017`) and task-registry resolution
|
|
124
|
+
(`JF0018`). The pen EMITS those documents and the compilers refuse
|
|
125
|
+
them; `test/linq/flow-pen.test.js` builds each one through the pen and
|
|
126
|
+
asserts the compiler's code, so the non-judgement is itself gated.
|
|
127
|
+
|
|
128
|
+
## 3. Worked examples
|
|
129
|
+
|
|
130
|
+
Every `js` fence below exports exactly one document, and the `json` fence
|
|
131
|
+
that follows it is what the pen emits — executed by
|
|
132
|
+
`test/linq/pen-docs.test.js`. FLOW-FORMAT §2's machine and §6's dataflow
|
|
133
|
+
are additionally rebuilt through the pen and held BYTE-equal to the
|
|
134
|
+
format doc's own fences by `test/linq/flow-pen.test.js`, and every
|
|
135
|
+
document below validates under its published grammar.
|
|
136
|
+
|
|
137
|
+
### 3.1 A machine with a guard
|
|
138
|
+
|
|
139
|
+
Three states, three transitions, one guard, and a wildcard listed last —
|
|
140
|
+
document order is the whole priority scheme (§4), so a fallback goes at
|
|
141
|
+
the bottom and nothing else is needed to express precedence.
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
import * as s from '@jarenjs/linq/schema';
|
|
145
|
+
import { defineFsm, on, state } from '@jarenjs/linq/flow';
|
|
146
|
+
|
|
147
|
+
export const review = defineFsm({
|
|
148
|
+
initial: 'draft',
|
|
149
|
+
states: ['draft', 'review', state('published', { final: true })],
|
|
150
|
+
transitions: [
|
|
151
|
+
on('draft', 'submit').to('review'),
|
|
152
|
+
on('review', 'approve', { payload: s.object({ fresh: s.boolean(), by: s.string() }) })
|
|
153
|
+
.when((sc) => sc.payload.fresh)
|
|
154
|
+
.to('published'),
|
|
155
|
+
on('review').to('draft'),
|
|
156
|
+
],
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{
|
|
162
|
+
"$fsm": "0.1",
|
|
163
|
+
"initial": "draft",
|
|
164
|
+
"states": ["draft", "review", { "id": "published", "final": true }],
|
|
165
|
+
"transitions": [
|
|
166
|
+
{ "from": "draft", "event": "submit", "to": "review" },
|
|
167
|
+
{ "from": "review", "event": "approve", "guard": "$.payload.fresh", "to": "published" },
|
|
168
|
+
{ "from": "review", "to": "draft" }
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Three things to read off it. A bare string in `states` stays a bare
|
|
174
|
+
string — the format's own shorthand for `{ id }`, and the shape the
|
|
175
|
+
`jaren-workflow` projection carries — while `state(id, options)` writes
|
|
176
|
+
the object form. The wildcard writes NO `event` member, which is what
|
|
177
|
+
makes it match anything. And the `payload` builder emitted nothing: it
|
|
178
|
+
typed `sc.payload` inside the guard and left the document alone.
|
|
179
|
+
|
|
180
|
+
The guard `"$.payload.fresh"` is a path, and a path is what a captured
|
|
181
|
+
member read records. Send `approve` with `{ fresh: false }` and this
|
|
182
|
+
machine does not stay put — the guard fails, the wildcard below it
|
|
183
|
+
matches, and the step lands on `draft`. That is §4's selection rule
|
|
184
|
+
working exactly as written, and it is the reason a fallback's POSITION is
|
|
185
|
+
part of the design.
|
|
186
|
+
|
|
187
|
+
### 3.2 The same machine with effects, and an effect's `with`
|
|
188
|
+
|
|
189
|
+
An effect is a `{ run, with? }` descriptor. The engine never invokes it:
|
|
190
|
+
a fired transition RETURNS descriptors as data and the host's registry
|
|
191
|
+
runs them, which is the boundary that keeps the machine pure.
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
import * as s from '@jarenjs/linq/schema';
|
|
195
|
+
import { defineFsm, effect, on, state } from '@jarenjs/linq/flow';
|
|
196
|
+
|
|
197
|
+
export const reviewed = defineFsm({
|
|
198
|
+
initial: 'draft',
|
|
199
|
+
states: [
|
|
200
|
+
'draft',
|
|
201
|
+
'review',
|
|
202
|
+
state('published', { entry: [effect('announce', (sc) => ({ by: sc.payload.by, from: sc.state }))], final: true }),
|
|
203
|
+
],
|
|
204
|
+
transitions: [
|
|
205
|
+
on('draft', 'submit').to('review'),
|
|
206
|
+
on('review', 'approve', { payload: s.object({ fresh: s.boolean(), by: s.string() }) })
|
|
207
|
+
.when((sc) => sc.payload.fresh)
|
|
208
|
+
.to('published'),
|
|
209
|
+
on('review').to('draft').effects([effect('toast', () => ({ text: 'sent back' }))]),
|
|
210
|
+
],
|
|
211
|
+
});
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
{
|
|
216
|
+
"$fsm": "0.1",
|
|
217
|
+
"initial": "draft",
|
|
218
|
+
"states": [
|
|
219
|
+
"draft",
|
|
220
|
+
"review",
|
|
221
|
+
{ "id": "published",
|
|
222
|
+
"entry": [{ "run": "announce", "with": { "by": "$.payload.by", "from": "$.state" } }],
|
|
223
|
+
"final": true }
|
|
224
|
+
],
|
|
225
|
+
"transitions": [
|
|
226
|
+
{ "from": "draft", "event": "submit", "to": "review" },
|
|
227
|
+
{ "from": "review", "event": "approve", "guard": "$.payload.fresh", "to": "published" },
|
|
228
|
+
{ "from": "review", "to": "draft",
|
|
229
|
+
"effects": [{ "run": "toast", "with": { "text": "sent back" } }] }
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The two `with` members are the same capture in two spellings. `announce`
|
|
235
|
+
reads the step scope, so its members record paths (`$.payload.by`,
|
|
236
|
+
`$.state`); `toast` returns a literal, so its `with` is an object
|
|
237
|
+
CONSTRUCTOR — `{ "text": "sent back" }`, not `{ "$const": … }` — for the
|
|
238
|
+
reason [JSLT-PEN.md](JSLT-PEN.md) §1.1 point 4 gives, and because it is
|
|
239
|
+
the spelling FLOW-FORMAT §2's own example carries.
|
|
240
|
+
|
|
241
|
+
Stepping `approve` with `{ fresh: true, by: 'ada' }` from `review`
|
|
242
|
+
answers
|
|
243
|
+
`{ changed: true, state: 'published', effects: [{ run: 'announce', with: { by: 'ada', from: 'review' } }], final: true, errors: [] }`.
|
|
244
|
+
The `with` resolved against the scope; the handler named `announce` is
|
|
245
|
+
the host's to provide. The engine has NO code for an unregistered effect
|
|
246
|
+
handler and could not have one — FLOW-FORMAT §1.1 makes effect execution
|
|
247
|
+
a non-goal, so `compileFsm` never looks a name up and never calls
|
|
248
|
+
anything. A `run` nobody registered is a descriptor the host quietly
|
|
249
|
+
drops, which is worth knowing because it is one of the few mistakes in
|
|
250
|
+
this pen's surface that nothing on either side reports.
|
|
251
|
+
|
|
252
|
+
### 3.3 A guard over the whole step scope
|
|
253
|
+
|
|
254
|
+
The case that motivates the capture. A guard comparing two members of the
|
|
255
|
+
scope, and reading the event name besides, is an operator document — not
|
|
256
|
+
a path — and there is no string a writer could type that means it.
|
|
257
|
+
|
|
258
|
+
```js
|
|
259
|
+
import * as s from '@jarenjs/linq/schema';
|
|
260
|
+
import { defineFsm, on } from '@jarenjs/linq/flow';
|
|
261
|
+
|
|
262
|
+
export const scored = defineFsm({
|
|
263
|
+
initial: 'review',
|
|
264
|
+
states: ['review', 'published'],
|
|
265
|
+
transitions: [
|
|
266
|
+
on('review', 'approve', { payload: s.object({ score: s.number() }) })
|
|
267
|
+
.when((sc) => sc.context.threshold.le(sc.payload.score).and(sc.event.eq('approve')))
|
|
268
|
+
.to('published'),
|
|
269
|
+
],
|
|
270
|
+
context: s.object({ threshold: s.number() }),
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
```json
|
|
275
|
+
{
|
|
276
|
+
"$fsm": "0.1",
|
|
277
|
+
"initial": "review",
|
|
278
|
+
"states": ["review", "published"],
|
|
279
|
+
"transitions": [
|
|
280
|
+
{ "from": "review", "event": "approve",
|
|
281
|
+
"guard": { "$and": [ { "$le": ["$.context.threshold", "$.payload.score"] },
|
|
282
|
+
{ "$eq": ["$.event", "approve"] } ] },
|
|
283
|
+
"to": "published" }
|
|
284
|
+
]
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`compileFsm(scored).step('review', 'approve', { payload: { score: 8 }, context: { threshold: 5 } })`
|
|
289
|
+
fires; the same call with `{ score: 3 }` reports
|
|
290
|
+
`{ changed: false, state: 'review', … }` — an unhandled step, not an
|
|
291
|
+
error (§4).
|
|
292
|
+
|
|
293
|
+
This is what §4.2's refusal is protecting. A writer who wants this
|
|
294
|
+
condition and reaches for a string gets a guard that is EBV-true for
|
|
295
|
+
every step, forever, silently. The pen refuses the string; the callback
|
|
296
|
+
is the route.
|
|
297
|
+
|
|
298
|
+
**A TypeScript caller writes one more thing here.** `sc.payload` is typed
|
|
299
|
+
by the event's own declaration on the same call, but `sc.context` is not:
|
|
300
|
+
`on()` is evaluated before `defineFsm()` ever sees the `context` builder,
|
|
301
|
+
so the scope's context is the honest top and `sc.context.threshold` does
|
|
302
|
+
not compile. The annotation is where the type comes from —
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
on('review', 'approve', { payload: Score })
|
|
306
|
+
.when((sc: Scope<{ threshold: number }, { score: number }>) =>
|
|
307
|
+
sc.context.threshold.le(sc.payload.score))
|
|
308
|
+
.to('published')
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
— and §5.1 says why it cannot be inferred. The emitted document is
|
|
312
|
+
identical either way; the annotation buys the compiler, not the
|
|
313
|
+
document.
|
|
314
|
+
|
|
315
|
+
### 3.4 A dataflow: input, two tasks, output
|
|
316
|
+
|
|
317
|
+
Two `task` nodes chained, the second checkpointed, and a `select` on the
|
|
318
|
+
delivering edge. `run` is a NAME the pen writes and never resolves — the
|
|
319
|
+
registry a host hands `compileDag` owns the handler.
|
|
320
|
+
|
|
321
|
+
```js
|
|
322
|
+
import { defineDag, edge, input, output, task } from '@jarenjs/linq/flow';
|
|
323
|
+
|
|
324
|
+
export const writing = defineDag({
|
|
325
|
+
nodes: {
|
|
326
|
+
brief: input(),
|
|
327
|
+
draft: task('llm', (v) => ({ prompt: v.topic })),
|
|
328
|
+
review: task('critic', (v) => ({ text: v }), { version: '1' }).checkpoint(),
|
|
329
|
+
out: output(),
|
|
330
|
+
},
|
|
331
|
+
edges: [
|
|
332
|
+
edge('brief', 'draft'),
|
|
333
|
+
edge('draft', 'review'),
|
|
334
|
+
edge('review', 'out', { select: (v) => v.get('verdict') }),
|
|
335
|
+
],
|
|
336
|
+
});
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
```json
|
|
340
|
+
{
|
|
341
|
+
"$dag": "0.1",
|
|
342
|
+
"nodes": {
|
|
343
|
+
"brief": { "kind": "input" },
|
|
344
|
+
"draft": { "kind": "task", "run": "llm", "with": { "prompt": "$.topic" } },
|
|
345
|
+
"review": { "kind": "task", "run": "critic", "version": "1", "with": { "text": "$" }, "checkpoint": true },
|
|
346
|
+
"out": { "kind": "output" }
|
|
347
|
+
},
|
|
348
|
+
"edges": [
|
|
349
|
+
{ "from": "brief", "to": "draft" },
|
|
350
|
+
{ "from": "draft", "to": "review" },
|
|
351
|
+
{ "from": "review", "to": "out", "select": "$['verdict']" }
|
|
352
|
+
]
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
`review` carries a `version` because it is checkpointed: a recorded value
|
|
357
|
+
is replayed on a later run only while the handler that produced it is the
|
|
358
|
+
same one, so that identity is DECLARED — the registry must hand
|
|
359
|
+
`compileDag` the same token as `{ run, version }`, and a disagreement is
|
|
360
|
+
`JF0019` before any node runs. `.checkpoint()` refuses a task that has
|
|
361
|
+
not declared one.
|
|
362
|
+
|
|
363
|
+
`review`'s `with` is `{ "text": "$" }`: the callback returned the scope
|
|
364
|
+
value itself, and the scope value of a node with one unported inbound
|
|
365
|
+
edge IS the delivered value, verbatim (§6.1). `checkpoint: true` is
|
|
366
|
+
written last whatever order `.checkpoint()` was called in, and it is
|
|
367
|
+
opt-in per node — with a checkpoint store configured, only `review`'s
|
|
368
|
+
value is recorded, and a resumed run seeds it instead of calling the
|
|
369
|
+
handler again (§7.6).
|
|
370
|
+
|
|
371
|
+
`v.get('verdict')` in the `select` records `"$['verdict']"` rather than
|
|
372
|
+
`"$.verdict"`. Both are the same path; `get()` always writes the bracket
|
|
373
|
+
form because it takes an arbitrary string. Reach for it when a member
|
|
374
|
+
name is not an identifier, or when it collides with a chain method — a
|
|
375
|
+
member called `count` read as `v.count` answers the method, not the
|
|
376
|
+
member.
|
|
377
|
+
|
|
378
|
+
### 3.5 A `query` node, and the rule that changes its shape
|
|
379
|
+
|
|
380
|
+
A `query` node runs a Jaren JSON Query over its input scope. This example
|
|
381
|
+
takes a query DOCUMENT rather than a callback, because FLOW-FORMAT §6's
|
|
382
|
+
own example does and because a FLWOR phrase naming its binding `r` is not
|
|
383
|
+
something the chain emits (it packs and names its binding `it`).
|
|
384
|
+
|
|
385
|
+
```js
|
|
386
|
+
import { defineDag, edge, input, output, query } from '@jarenjs/linq/flow';
|
|
387
|
+
|
|
388
|
+
export const longEnough = defineDag({
|
|
389
|
+
nodes: {
|
|
390
|
+
subs: input(),
|
|
391
|
+
long: query({ $for: { r: '$[*]' }, $where: { $ge: ['$r.words', 500] }, $return: '$r' }),
|
|
392
|
+
out: output(),
|
|
393
|
+
},
|
|
394
|
+
edges: [edge('subs', 'long'), edge('long', 'out')],
|
|
395
|
+
});
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
```json
|
|
399
|
+
{
|
|
400
|
+
"$dag": "0.1",
|
|
401
|
+
"nodes": {
|
|
402
|
+
"subs": { "kind": "input" },
|
|
403
|
+
"long": { "kind": "query",
|
|
404
|
+
"query": { "$for": { "r": "$[*]" }, "$where": { "$ge": ["$r.words", 500] }, "$return": "$r" } },
|
|
405
|
+
"out": { "kind": "output" }
|
|
406
|
+
},
|
|
407
|
+
"edges": [
|
|
408
|
+
{ "from": "subs", "to": "long" },
|
|
409
|
+
{ "from": "long", "to": "out" }
|
|
410
|
+
]
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
**A node's result carries the query engine's singleton rule, and the
|
|
415
|
+
result shape changes with the data.** `compileDag` calls a compiled query
|
|
416
|
+
through its default entry point, and QUERY-FORMAT rule 5 identifies a
|
|
417
|
+
one-item sequence with the item. So this graph, run three times:
|
|
418
|
+
|
|
419
|
+
```json
|
|
420
|
+
[
|
|
421
|
+
{ "input": [{"name":"ada","age":36},{"name":"kit","age":9},{"name":"lin","age":20}],
|
|
422
|
+
"result": [{"name":"ada","age":36},{"name":"lin","age":20}] },
|
|
423
|
+
{ "input": [{"name":"ada","age":36},{"name":"kit","age":9}],
|
|
424
|
+
"result": {"name":"ada","age":36} },
|
|
425
|
+
{ "input": [{"name":"kit","age":9}],
|
|
426
|
+
"result": null }
|
|
427
|
+
]
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Two survivors give an array, ONE survivor gives the row itself, none
|
|
431
|
+
gives `null`. FLOW-FORMAT §6 states only the empty case, and the
|
|
432
|
+
difference is invisible until a downstream `$[*]` iterates an object's
|
|
433
|
+
values instead of an array's items. The pen cannot fix it — this is the
|
|
434
|
+
engine's reading of a document the pen wrote faithfully — so it is
|
|
435
|
+
recorded in [docs/ROADMAP.md](../../../docs/ROADMAP.md) under
|
|
436
|
+
`@jarenjs/flow`, where the resolution is a format decision (a §6 sentence
|
|
437
|
+
and a worked example, or a node-level "always a sequence" option). Until
|
|
438
|
+
then: a `query` node feeding anything that expects a list wants a `$for`
|
|
439
|
+
whose `$return` is explicitly an array constructor, or a downstream node
|
|
440
|
+
that tolerates both.
|
|
441
|
+
|
|
442
|
+
### 3.6 A `jslt` node
|
|
443
|
+
|
|
444
|
+
A `jslt` node's stylesheet is the JSLT pen's document
|
|
445
|
+
([JSLT-PEN.md](JSLT-PEN.md)), embedded whole. This is the composition the
|
|
446
|
+
two pens exist for: a dataflow that ends in a rendered view, written
|
|
447
|
+
entirely by code.
|
|
448
|
+
|
|
449
|
+
```js
|
|
450
|
+
import { defineDag, edge, input, jslt, output } from '@jarenjs/linq/flow';
|
|
451
|
+
import { apply, rule, stylesheet } from '@jarenjs/linq/jslt';
|
|
452
|
+
|
|
453
|
+
export const listing = defineDag({
|
|
454
|
+
nodes: {
|
|
455
|
+
articles: input(),
|
|
456
|
+
list: jslt(stylesheet([
|
|
457
|
+
rule('$', (v) => ['ul', {}, [apply(v.all())]]),
|
|
458
|
+
rule('$[*]', (v) => ['li', {}, v.title]),
|
|
459
|
+
])),
|
|
460
|
+
out: output(),
|
|
461
|
+
},
|
|
462
|
+
edges: [edge('articles', 'list'), edge('list', 'out')],
|
|
463
|
+
});
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
```json
|
|
467
|
+
{
|
|
468
|
+
"$dag": "0.1",
|
|
469
|
+
"nodes": {
|
|
470
|
+
"articles": { "kind": "input" },
|
|
471
|
+
"list": { "kind": "jslt",
|
|
472
|
+
"stylesheet": { "$jslt": "0.1", "rules": [
|
|
473
|
+
{ "match": "$", "body": ["ul", {}, [{ "$apply": "$[*]" }]] },
|
|
474
|
+
{ "match": "$[*]", "body": ["li", {}, "$.title"] } ] } },
|
|
475
|
+
"out": { "kind": "output" }
|
|
476
|
+
},
|
|
477
|
+
"edges": [
|
|
478
|
+
{ "from": "articles", "to": "list" },
|
|
479
|
+
{ "from": "list", "to": "out" }
|
|
480
|
+
]
|
|
481
|
+
}
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
Over `[{ title: 'ada' }, { title: 'lin' }]` this runs to
|
|
485
|
+
`['ul', {}, [['li', {}, 'ada'], ['li', {}, 'lin']]]` — a `jaren-vnode`
|
|
486
|
+
tree, which is what [JSLT-PEN.md](JSLT-PEN.md) §3.7 is about. The
|
|
487
|
+
envelope form and the bare rules array are both accepted here; the
|
|
488
|
+
envelope is what `stylesheet()` writes, and a rules array is what a hand
|
|
489
|
+
written node usually carries. Nothing about the stylesheet is judged by
|
|
490
|
+
this pen — `jslt()` checks only that the value is JSON, and `JF0014` is
|
|
491
|
+
the compiler's if the stylesheet itself is wrong.
|
|
492
|
+
|
|
493
|
+
### 3.7 Ports, a constant, and `typedTasks` over the registry
|
|
494
|
+
|
|
495
|
+
A node with more than one inbound edge needs ports: when any inbound edge
|
|
496
|
+
names one, every inbound edge MUST, and the node's `$` becomes the object
|
|
497
|
+
of port-named values in EDGE document order (§6.1). `typedTasks(graph,
|
|
498
|
+
registry)` binds the handler table to the graph — identity at runtime,
|
|
499
|
+
a compile-time check that the table has one handler per declared task
|
|
500
|
+
name.
|
|
501
|
+
|
|
502
|
+
```js
|
|
503
|
+
import { constant, defineDag, edge, input, output, task, typedTasks } from '@jarenjs/linq/flow';
|
|
504
|
+
|
|
505
|
+
const answering = defineDag({
|
|
506
|
+
nodes: {
|
|
507
|
+
question: input(),
|
|
508
|
+
facts: constant({ product: 'jarenjs', version: '0.52.7' }),
|
|
509
|
+
answer: task('llm', (v) => ({ prompt: v.get('q'), facts: v.get('f') })),
|
|
510
|
+
out: output(),
|
|
511
|
+
},
|
|
512
|
+
edges: [
|
|
513
|
+
edge('question', 'answer', { port: 'q' }),
|
|
514
|
+
edge('facts', 'answer', { port: 'f' }),
|
|
515
|
+
edge('answer', 'out', { select: (v) => v.get('text') }),
|
|
516
|
+
],
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// the registry the host will hand compileDag, checked against the graph's task names
|
|
520
|
+
void typedTasks(answering, {
|
|
521
|
+
llm: async ({ with: w }) => ({ text: `${w.prompt} — ${w.facts.product} ${w.facts.version}` }),
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
export const graph = answering;
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
```json
|
|
528
|
+
{
|
|
529
|
+
"$dag": "0.1",
|
|
530
|
+
"nodes": {
|
|
531
|
+
"question": { "kind": "input" },
|
|
532
|
+
"facts": { "kind": "const", "value": { "product": "jarenjs", "version": "0.52.7" } },
|
|
533
|
+
"answer": { "kind": "task", "run": "llm",
|
|
534
|
+
"with": { "prompt": "$['q']", "facts": "$['f']" } },
|
|
535
|
+
"out": { "kind": "output" }
|
|
536
|
+
},
|
|
537
|
+
"edges": [
|
|
538
|
+
{ "from": "question", "to": "answer", "port": "q" },
|
|
539
|
+
{ "from": "facts", "to": "answer", "port": "f" },
|
|
540
|
+
{ "from": "answer", "to": "out", "select": "$['text']" }
|
|
541
|
+
]
|
|
542
|
+
}
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
`typedTasks` emits nothing — it is the identity function, and the fence
|
|
546
|
+
exports the graph. Rename `llm` to `model` in the registry and the fence
|
|
547
|
+
stops COMPILING, which is the whole point: the check is
|
|
548
|
+
`{ [K in TasksOf<D>]: TaskHandler }`, and the runtime check stays
|
|
549
|
+
`JF0018` for a host that assembled its registry dynamically. §5.3 states
|
|
550
|
+
the other direction — annotating the node map with `NodesFor<Registry>`,
|
|
551
|
+
which catches a misspelled `task('nope')` at the node rather than at the
|
|
552
|
+
binding.
|
|
553
|
+
|
|
554
|
+
## 4. Refusals
|
|
555
|
+
|
|
556
|
+
The flow pen raises these three `LinqBuildError` codes and no others —
|
|
557
|
+
`test/linq/pen-docs.test.js` holds this list equal, in both directions,
|
|
558
|
+
to the codes `packages/linq/src/flow/` throws. The full condition each
|
|
559
|
+
code states across every pen is the binder's,
|
|
560
|
+
[LINQ-FORMAT.md](LINQ-FORMAT.md) §1.3.
|
|
561
|
+
|
|
562
|
+
| Code | What this pen raises it for |
|
|
563
|
+
|---|---|
|
|
564
|
+
| `JL0101` | a value this pen cannot spell, a member it does not know, or a name → value map it cannot read |
|
|
565
|
+
| `JL0102` | a guard given as a plain STRING, or a state or node id no declaration carries |
|
|
566
|
+
| `JL0104` | any external at all: both engines evaluate with one `$` and nothing else |
|
|
567
|
+
|
|
568
|
+
`packages/linq/src/flow/` carries **33 throw sites** — 30 `JL0101` and 3
|
|
569
|
+
`JL0102`. Two families of condition reach a caller through this pen
|
|
570
|
+
without being thrown in its directory: the effect descriptor and its list
|
|
571
|
+
(`packages/linq/src/effect.js`, shared with the app pen) and the shared
|
|
572
|
+
capture's external check (`packages/linq/src/capture-root.js`), which is
|
|
573
|
+
where every one of this pen's `JL0104`s comes from — the code appears in
|
|
574
|
+
`src/flow/` only in the comments that explain it, and the refusal gate
|
|
575
|
+
counts those, correctly, as the pen owing the reader a row.
|
|
576
|
+
|
|
577
|
+
Every message below is the one the pen raised when the spelling beside it
|
|
578
|
+
was run, with the code prefix (`JL0101: `) removed. `docPath`, where the
|
|
579
|
+
refusal carries one, is the JSON pointer of the node being assembled and
|
|
580
|
+
is appended to the message text as well (`… at /transitions/0/to`).
|
|
581
|
+
|
|
582
|
+
### 4.1 `JL0101` — the value, the member and the map
|
|
583
|
+
|
|
584
|
+
**The machine.**
|
|
585
|
+
|
|
586
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
587
|
+
|---|---|---|
|
|
588
|
+
| `defineFsm('$')` | `defineFsm() takes { initial, states, transitions, context? }, got a string` | the four members |
|
|
589
|
+
| `defineFsm({ states, transitions })` | `defineFsm() needs an initial state — the format requires the member; pass null for a machine that chooses none (a session then starts with an explicit state)` — `docPath` `/initial` | `initial: 'draft'`, or `null` |
|
|
590
|
+
| `defineFsm({ …, nope: 1 })` | `defineFsm() does not take 'nope' — it takes initial, states, transitions, context` — `docPath` `/nope` | the four members |
|
|
591
|
+
| `defineFsm({ …, context: {} })` | `defineFsm() context is a schema-pen builder that types the host data a guard reads (FLOW-FORMAT §3) — the format carries no context schema, so nothing is emitted for it; got a Object instance` — `docPath` `/context` | `s.object({ … })` |
|
|
592
|
+
| `defineFsm({ …, states: '$' })` | `defineFsm() states is an array of ids and state() declarations, got a string` — `docPath` `/states` | an array |
|
|
593
|
+
| `defineFsm({ …, states: [{ id: 'a' }] })` | `defineFsm() states[0] is an id or state(id, options?), got a Object instance` — `docPath` `/states/0` | `'a'` or `state('a')` |
|
|
594
|
+
| `defineFsm({ initial: 42, … })` | `defineFsm() initial takes a state id — a non-empty string, got 42` | a declared id |
|
|
595
|
+
| `defineFsm({ …, transitions: '$' })` | `defineFsm() transitions is an array of on(…) declarations, got a string` — `docPath` `/transitions` | an array |
|
|
596
|
+
| `defineFsm({ …, transitions: [{ from: 'a', to: 'a' }] })` | `defineFsm() transitions[0] is on(from, event?).to(state), got a Object instance` — `docPath` `/transitions/0` | `on('a', 'go').to('a')` |
|
|
597
|
+
| `defineFsm({ …, transitions: [on('a', 'go')] })` | `defineFsm() transitions[0] never named its target — on('a', 'go') needs .to(state)` — `docPath` `/transitions/0` | finish it with `.to(state)` |
|
|
598
|
+
|
|
599
|
+
**A state, a transition, an effect.**
|
|
600
|
+
|
|
601
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
602
|
+
|---|---|---|
|
|
603
|
+
| `state('')` | `state() takes a state id — a non-empty string, got a string` | a non-empty id |
|
|
604
|
+
| `state('a', 'x')` | `state() options are { entry?, exit?, final? }, got a string` | the options object |
|
|
605
|
+
| `state('a', { onEntry: [] })` | `state() does not take 'onEntry' — it takes entry, exit, final` — `docPath` `/onEntry` | `entry` |
|
|
606
|
+
| `state('a', { final: 'yes' })` | `state() final is a boolean, got a string` — `docPath` `/final` | `true` |
|
|
607
|
+
| `state('a', { entry: 'x' })` | `state() entry is an array of effect() descriptors, got a string` | an array |
|
|
608
|
+
| `state('a', { entry: [{ run: 'x' }] })` | `state() entry[0] is effect(run, with?), got a Object instance` | `effect('x')` |
|
|
609
|
+
| `on(1)` | `on() takes a state id — a non-empty string, got 1` | a declared id |
|
|
610
|
+
| `on('a', 7)` | `on() takes an event name as a non-empty string, or null for the wildcard that matches any event (FLOW-FORMAT §2), got 7` — `docPath` `/event` | `'go'`, or `null` |
|
|
611
|
+
| `on('a', 'go', 'x')` | `on() options are { payload? }, got a string` | `{ payload: … }` |
|
|
612
|
+
| `on('a', 'go', { data: s.string() })` | `on() does not take 'data' — it takes payload` — `docPath` `/data` | `payload` |
|
|
613
|
+
| `on('a', 'go', { payload: { type: 'object' } })` | `on() payload is a schema-pen builder that types the event's payload — the format carries no payload schema, so nothing is emitted for it; got a Object instance` — `docPath` `/payload` | `s.object({ … })` |
|
|
614
|
+
| `on('a', 'go').to(42)` | `to() takes a state id — a non-empty string, got 42` | a declared id |
|
|
615
|
+
| `on('a', 'go').effects('x')` | `effects() is an array of effect() descriptors, got a string` | an array |
|
|
616
|
+
| `on('a', 'go').when(new Date(0))` | `when() received a Date instance, which is not JSON — a document carries null, booleans, finite numbers (never -0), strings, arrays and plain objects, and nothing else` | a callback, or a query document |
|
|
617
|
+
| `effect('')` | `effect() takes the handler name as a non-empty string, got a string` — `docPath` `/run` | a registered name |
|
|
618
|
+
| `effect('x', { with: Symbol('s') })` | `effect() with received a Object instance, which is not JSON — a document carries null, booleans, finite numbers (never -0), strings, arrays and plain objects, and nothing else` | a callback, or a query document |
|
|
619
|
+
|
|
620
|
+
**The dataflow.**
|
|
621
|
+
|
|
622
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
623
|
+
|---|---|---|
|
|
624
|
+
| `defineDag([])` | `defineDag() takes { nodes, edges }, got a Array instance` | the two members |
|
|
625
|
+
| `defineDag({ …, tasks: {} })` | `defineDag() does not take 'tasks' — it takes nodes, edges` — `docPath` `/tasks` | the registry goes to `compileDag` |
|
|
626
|
+
| `defineDag({ nodes: '$', edges: [] })` | `defineDag() nodes is a plain object of id → node declaration, got a string` — `docPath` `/nodes` | an object |
|
|
627
|
+
| `defineDag({ nodes: { __proto__: input() }, edges: [] })` | `defineDag() nodes received a map whose prototype was replaced: a '__proto__:' key in an object literal sets the prototype instead of adding a member, so that member is not there to emit — spell it { ['__proto__']: … }, which is an own key` — `docPath` `/nodes` | `{ ['__proto__']: input() }` |
|
|
628
|
+
| `defineDag({ nodes: {}, edges: [] })` | `defineDag() needs at least one node` — `docPath` `/nodes` | at least one |
|
|
629
|
+
| `defineDag({ nodes: { a: { kind: 'input' } }, … })` | `defineDag() node 'a' is input(), constant(), query(), jslt(), task() or output(), got a Object instance` — `docPath` `/nodes/a` | `input()` |
|
|
630
|
+
| `defineDag({ …, edges: {} })` | `defineDag() edges is an array of edge(from, to) declarations, got a Object instance` — `docPath` `/edges` | an array |
|
|
631
|
+
| `defineDag({ …, edges: [{ from: 'a', to: 'a' }] })` | `defineDag() edges[0] is edge(from, to, options?), got a Object instance` — `docPath` `/edges/0` | `edge('a', 'a')` |
|
|
632
|
+
| `constant(undefined)` | `constant() takes the value the node yields — every JSON value, null included; undefined is not one` — `docPath` `/value` | any JSON value |
|
|
633
|
+
| `constant(new Date(0))` | `constant() received a Date instance, which is not JSON — …` | its ISO string, or its epoch number |
|
|
634
|
+
| `query(undefined)` | `query() takes a callback (v) => … captured over the node input, or a query document` — `docPath` `/query` | one of the two |
|
|
635
|
+
| `jslt(undefined)` | `jslt() takes a stylesheet document — the JSLT pen's stylesheet(…) or rule array, or one written by hand` — `docPath` `/stylesheet` | `stylesheet([…])` |
|
|
636
|
+
| `task('')` | `task() takes the handler name as a non-empty string, got a string` — `docPath` `/run` | a registry name |
|
|
637
|
+
| `edge('', 'b')`, `edge('a', '')` | `edge() takes the producing node id as a non-empty string, got a string` — `docPath` `/from` | a declared id |
|
|
638
|
+
| `edge('a', 'b', 'x')` | `edge() options are { port?, select? }, got a string` | the options object |
|
|
639
|
+
| `edge('a', 'b', { port: '' })` | `edge() port is a non-empty string, got a string` — `docPath` `/port` | a port name |
|
|
640
|
+
| `edge('a', 'b', { ports: 'x' })` | `edge() does not take 'ports' — it takes port, select` — `docPath` `/ports` | `port` |
|
|
641
|
+
|
|
642
|
+
A refusal a captured member can raise that is the CHAIN's rather than
|
|
643
|
+
this pen's: `effect('x', () => new Date(0))` is `JL0005` ("a captured
|
|
644
|
+
expression cannot embed a Date instance — it carries no own enumerable
|
|
645
|
+
members, so it would embed as `{}`"). It is documented in
|
|
646
|
+
[QUERY-PEN.md](QUERY-PEN.md) §9.
|
|
647
|
+
|
|
648
|
+
### 4.2 `JL0102` — a guard given as a plain string
|
|
649
|
+
|
|
650
|
+
This deserves its own prose because a reader will hit it, and because the
|
|
651
|
+
message alone does not explain why a perfectly reasonable-looking string
|
|
652
|
+
is refused.
|
|
653
|
+
|
|
654
|
+
FLOW-FORMAT §3 asserts a guard by **effective boolean value**. A string
|
|
655
|
+
that does not start with `$` is a literal string, and a non-empty literal
|
|
656
|
+
is EBV-true. So a guard spelled `'count > 3'` is not a condition the
|
|
657
|
+
engine fails to parse — it is a condition that passes, on every step,
|
|
658
|
+
forever, silently. That is deliberate in the FORMAT: the mermaid state
|
|
659
|
+
diagram projection carries opaque display guards lifted from diagram
|
|
660
|
+
labels (`[count > 3]`), and a picture's annotation MUST NOT change
|
|
661
|
+
execution.
|
|
662
|
+
|
|
663
|
+
The pen refuses to emit one, because a pen writing that document is not
|
|
664
|
+
projecting a diagram — it is writing code that meant something.
|
|
665
|
+
|
|
666
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
667
|
+
|---|---|---|
|
|
668
|
+
| `on('a', 'go').when('count > 3')` | `a guard given as a plain string is asserted by effective boolean value, and a non-empty literal is therefore VACUOUSLY TRUE (FLOW-FORMAT §3: a projected display guard must not change execution) — got "count > 3"; pass a body instead: .when((s) => s.payload.fresh)` — `docPath` `/guard` | `.when((sc) => sc.context.count.gt(3))` |
|
|
669
|
+
| `on('a', 'go').when('$.payload.fresh')` | the same message, with `"$.payload.fresh"` | `.when((sc) => sc.payload.fresh)` |
|
|
670
|
+
|
|
671
|
+
The second row is the one that catches people. `'$.payload.fresh'` IS a
|
|
672
|
+
valid guard document — the engine would read it as a path and evaluate it
|
|
673
|
+
correctly. The pen refuses it anyway, and the reason is that it cannot
|
|
674
|
+
tell that string apart from the first row's at build time without
|
|
675
|
+
becoming a JSONPath parser, which LINQ-FORMAT §1.1 rule 1 forbids: a pen
|
|
676
|
+
mirrors the engine's rules, it does not re-implement its compiler.
|
|
677
|
+
Refusing every plain string is the rule that has no wrong answers, and
|
|
678
|
+
the callback is both shorter and typed:
|
|
679
|
+
|
|
680
|
+
```js
|
|
681
|
+
// refused, though the document it would write is correct
|
|
682
|
+
on('review', 'approve').when('$.payload.fresh').to('published')
|
|
683
|
+
|
|
684
|
+
// what the writer meant, captured — and sc.payload is typed by the event's declaration
|
|
685
|
+
on('review', 'approve', { payload: Approval }).when((sc) => sc.payload.fresh).to('published')
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
A guard written as a query DOCUMENT rides verbatim and is not refused —
|
|
689
|
+
`.when({ $gt: ['$.payload.n', 3] })` emits exactly that. The refusal is
|
|
690
|
+
on the string form alone, because the string form is the ambiguous one.
|
|
691
|
+
|
|
692
|
+
### 4.3 `JL0102` — an id no declaration carries
|
|
693
|
+
|
|
694
|
+
The other `JL0102` condition, and the pen's clearest case of catching the
|
|
695
|
+
engine's own rule one step earlier. Both machines and dataflows have it.
|
|
696
|
+
|
|
697
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
698
|
+
|---|---|---|
|
|
699
|
+
| `defineFsm({ initial: 'nope', states: ['draft'], transitions: [] })` | `defineFsm() initial names the state 'nope', which "states" does not declare — the declared states are 'draft'` — `docPath` `/initial` | a declared id — the message lists them |
|
|
700
|
+
| `on('draft', 'go').to('nope')` in a machine declaring `draft`, `review` | `transition 0 names the state 'nope', which "states" does not declare — the declared states are 'draft', 'review'` — `docPath` `/transitions/0/to` | as above |
|
|
701
|
+
| `on('nope', 'go').to('draft')` | the same message, `docPath` `/transitions/0/from` | as above |
|
|
702
|
+
| `edge('nope', 'out')` in a graph declaring `rows`, `out` | `edge 0 names the node 'nope', which "nodes" does not declare — the declared nodes are 'rows', 'out'` — `docPath` `/edges/0/from` | a declared id — the message lists them |
|
|
703
|
+
| `edge('rows', 'nope')` | the same message, `docPath` `/edges/0/to` | as above |
|
|
704
|
+
|
|
705
|
+
Every message names the id that was not found AND the ids that were,
|
|
706
|
+
which is what makes it useful for a typo: the reader does not have to
|
|
707
|
+
scroll back to the declaration to see what they meant to write.
|
|
708
|
+
|
|
709
|
+
Three checks in a row are worth naming, because they fire in a fixed
|
|
710
|
+
order and only the last one is the engine's:
|
|
711
|
+
|
|
712
|
+
1. **The type checker.** State ids and node ids are literal types read
|
|
713
|
+
off the declarations, so `to('nope')` and `edge('nope', 'out')` do not
|
|
714
|
+
compile in a typed consumer. That is the cheapest place to be told
|
|
715
|
+
(§5.2).
|
|
716
|
+
2. **The pen**, for a JavaScript consumer or a dynamically built id:
|
|
717
|
+
`JL0102` at `defineFsm()`/`defineDag()`, naming the id.
|
|
718
|
+
3. **The compiler**, for a hand-written document that never went through
|
|
719
|
+
the pen: `JF0004` (`initial` is neither `null` nor a declared state),
|
|
720
|
+
`JF0006` (a transition's `from` or `to` names no declared state) and
|
|
721
|
+
`JF0013` (an edge's `from` or `to` names no declared node).
|
|
722
|
+
|
|
723
|
+
### 4.4 `JL0104` — the closed world is EMPTY
|
|
724
|
+
|
|
725
|
+
A machine's guard, an effect's `with`, a `query` node, a task's `with`
|
|
726
|
+
and an edge's `select` bind NOTHING but `$`. Not `root`, not `path`, not
|
|
727
|
+
a parameter — both flow engines evaluate these with a single `$` and no
|
|
728
|
+
externals at all.
|
|
729
|
+
|
|
730
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
731
|
+
|---|---|---|
|
|
732
|
+
| `on('a', 'go').when((sc, x) => x.root)` | `a when() callback cannot bind 'root' — its query evaluates with no externals at all; anything else has nothing to bind to — when() evaluates over the step scope { state, event, payload, context } (FLOW-FORMAT §3), which its argument IS` | read the scope: `(sc) => sc.context.url` |
|
|
733
|
+
| `effect('x', (sc, y) => ({ n: y.rate }))` | the same, `an effect() with callback cannot bind 'rate'`, with the same step-scope advice | read the scope, or put the value in `context` |
|
|
734
|
+
| `query((v, x) => x.root)` | the same, `a query() callback cannot bind 'root'`, advised `query() evaluates over the node's input scope (FLOW-FORMAT §6.1), which its argument IS` | read the input: `(v) => v.all()` |
|
|
735
|
+
| `task('llm', (v, x) => x.root)` | the same, `a task() with callback cannot bind 'root'` | as above |
|
|
736
|
+
| `edge('a', 'b', { select: (v, x) => x.root })` | the same, `an edge() select callback cannot bind 'root'`, advised `edge() select evaluates over the source value (FLOW-FORMAT §6)` | as above |
|
|
737
|
+
|
|
738
|
+
Refusing at build time is the whole value of this check, and the reason
|
|
739
|
+
is FLOW-FORMAT §5.2: a guard whose evaluation fails does NOT fail the
|
|
740
|
+
step. It reads **false** and the failure is recorded. So a guard naming
|
|
741
|
+
an unbound external would compile, run, and quietly never fire — the
|
|
742
|
+
worst failure mode a state machine has. `JQ2006` at step time is a
|
|
743
|
+
recorded error nobody reads; `JL0104` at build time is a stack trace on
|
|
744
|
+
the line that wrote it.
|
|
745
|
+
|
|
746
|
+
**Why the message names no scope of its own.** The first half comes from
|
|
747
|
+
the shared capture (`packages/linq/src/capture-root.js`), which knows how
|
|
748
|
+
many externals the evaluator binds and nothing else — WHERE a query is
|
|
749
|
+
evaluated is the pen's fact, not the capture's, and this pen's five
|
|
750
|
+
members have three different answers. Each one supplies its own, which is
|
|
751
|
+
what the clause after the dash is. The word "callback" is this pen's too:
|
|
752
|
+
it has no rules.
|
|
753
|
+
|
|
754
|
+
## 5. The types
|
|
755
|
+
|
|
756
|
+
The declarations are `packages/linq/types/flow.d.ts` (285 lines), and
|
|
757
|
+
every claim below is pinned at compile level in
|
|
758
|
+
`test/consumer/linq-flow.ts` with a runtime twin in
|
|
759
|
+
`test/linq/flow-pen.test.js`. This subpath exports **no builder class, no
|
|
760
|
+
constant and no type guard** — the three kinds the mapping table excludes
|
|
761
|
+
are empty here, so §2 names the whole runtime surface.
|
|
762
|
+
|
|
763
|
+
```ts
|
|
764
|
+
import { compileDag, compileFsm, fsmToApp } from '@jarenjs/flow';
|
|
765
|
+
import { defineDag, defineFsm, edge, input, on, output, state, task, typedTasks } from '@jarenjs/linq/flow';
|
|
766
|
+
import type { ContextOf, Dag, EventsOf, Fsm, NodesFor, NodesOf, Scope, StatesOf, TasksOf } from '@jarenjs/linq/flow';
|
|
767
|
+
|
|
768
|
+
// over §3.1's machine — no `context` was declared, so ContextOf<> is the honest top
|
|
769
|
+
type State = StatesOf<typeof review>; // 'draft' | 'review' | 'published'
|
|
770
|
+
type Event = EventsOf<typeof review>; // 'submit' | 'approve' (a wildcard names none)
|
|
771
|
+
type NoCtx = ContextOf<typeof review>; // unknown
|
|
772
|
+
|
|
773
|
+
// over §3.3's machine, which declared one
|
|
774
|
+
type Ctx = ContextOf<typeof scored>; // { threshold: number }
|
|
775
|
+
|
|
776
|
+
const machine = compileFsm(review);
|
|
777
|
+
machine.step('review', 'approve', { payload: { fresh: true, by: 'ada' } });
|
|
778
|
+
fsmToApp(review); // a machine of string states projects to app documents
|
|
779
|
+
|
|
780
|
+
// over §3.7's graph
|
|
781
|
+
type Ids = NodesOf<typeof graph>; // 'question' | 'facts' | 'answer' | 'out'
|
|
782
|
+
type Names = TasksOf<typeof graph>; // 'llm'
|
|
783
|
+
compileDag(graph, { tasks: typedTasks(graph, { llm: askTheModel }) });
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
`Fsm<States, Events, Ctx>` and `Dag<Ids, Tasks>` carry their phantoms —
|
|
787
|
+
`__states`, `__events`, `__context`; `__nodes`, `__tasks` — declared and
|
|
788
|
+
never present at runtime, and the `…Of<>` helpers read them back. A
|
|
789
|
+
transition builder carries `__from`, `__to` and `__event`, and `on()`
|
|
790
|
+
answers a `PendingTransition` that has NO `__to` until `.to()` gives it
|
|
791
|
+
one: a transition that never named its target does not compile, before it
|
|
792
|
+
is the `JL0101` §4.1 shows.
|
|
793
|
+
|
|
794
|
+
### 5.1 How a captured member is typed, and where the annotation goes
|
|
795
|
+
|
|
796
|
+
The capture itself is the JSLT pen's, and it is explained once, in
|
|
797
|
+
[JSLT-PEN.md](JSLT-PEN.md) §1.1 — a callback run once at build time
|
|
798
|
+
against a recording proxy. What differs here is the closed world: this
|
|
799
|
+
pen calls the shared capture with an EMPTY external list, so a flow
|
|
800
|
+
callback has one argument and a second one binds nothing (§4.4). The
|
|
801
|
+
`fold: false` setting is the same, which is why an effect's literal
|
|
802
|
+
`with` is a constructor and not a `$const`.
|
|
803
|
+
|
|
804
|
+
A captured value's TYPE is the honest top until it is annotated or
|
|
805
|
+
declared, and the pen has three different answers for the three places
|
|
806
|
+
that matters:
|
|
807
|
+
|
|
808
|
+
| Where | How it is typed | Why not by inference |
|
|
809
|
+
|---|---|---|
|
|
810
|
+
| a guard's `sc.payload` | the event's own declaration: `on(from, event, { payload })` | it is the same call — nothing to defer |
|
|
811
|
+
| a guard's `sc.context` | ANNOTATION: `.when((sc: Scope<Thresholds>) => …)` | `on()` is evaluated before `defineFsm()` sees the `context` builder |
|
|
812
|
+
| an `effect()`'s scope | ANNOTATION: `effect('x', (sc: Scope<Thresholds, Approval>) => …)` | an `effect()` is written before the transition that carries it exists |
|
|
813
|
+
| a `query`/`task`/`select` value | ANNOTATION: `query((v: Expr<Row[]>) => …)` | a node's input scope is decided by its inbound EDGES, which are declared after it |
|
|
814
|
+
|
|
815
|
+
What `defineFsm({ context })` types is the MACHINE — `ContextOf<>` — and
|
|
816
|
+
that is the reading a host needs, because the host is what calls
|
|
817
|
+
`step(state, event, { context })`. The annotation inside a guard and the
|
|
818
|
+
builder on the machine are two statements of the same fact, and nothing
|
|
819
|
+
in TypeScript can derive the first from the second here.
|
|
820
|
+
|
|
821
|
+
### 5.2 Literal ids, and what does not compile
|
|
822
|
+
|
|
823
|
+
```ts
|
|
824
|
+
// @ts-expect-error — 'nope' is not a declared state (before JL0102, long before JF0006)
|
|
825
|
+
void defineFsm({ initial: 'draft', states: ['draft'], transitions: [on('draft', 'go').to('nope')] });
|
|
826
|
+
// @ts-expect-error — a transition that never named its target is not a transition
|
|
827
|
+
void defineFsm({ initial: 'draft', states: ['draft'], transitions: [on('draft', 'go')] });
|
|
828
|
+
// @ts-expect-error — the initial state must be declared too
|
|
829
|
+
void defineFsm({ initial: 'nope', states: ['draft'], transitions: [] });
|
|
830
|
+
// @ts-expect-error — 'nope' is not a declared node (before JL0102, long before JF0013)
|
|
831
|
+
void defineDag({ nodes: { i: input(), o: output() }, edges: [edge('nope', 'o')] });
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
The mechanism is `const` type parameters on every id-taking function plus
|
|
835
|
+
`StateIdOf<St[number]>` over the `states` array, so the ids come from the
|
|
836
|
+
declarations themselves rather than from a union the caller maintains. A
|
|
837
|
+
wildcard contributes `never` to the event union, which is why
|
|
838
|
+
`EventsOf<>` of a machine whose only transition is `on('a').to('b')` is
|
|
839
|
+
`never` and not `string`.
|
|
840
|
+
|
|
841
|
+
### 5.3 `defineDag<Tasks>` cannot exist — and what a caller writes instead
|
|
842
|
+
|
|
843
|
+
State it plainly, because a reader who reaches for the type parameter
|
|
844
|
+
will get a compiler error that does not explain itself: **there is no
|
|
845
|
+
`defineDag<Tasks>(…)` and there cannot be one.**
|
|
846
|
+
|
|
847
|
+
The reason is TypeScript's, not this pen's. A function's type arguments
|
|
848
|
+
are all-or-none: supply one explicitly and every other type parameter
|
|
849
|
+
stops being inferred and falls back to its constraint or its default. The
|
|
850
|
+
node map's literal ids (`'rows' | 'out'`) and its task names are INFERRED
|
|
851
|
+
from the argument, so a signature that also took `Tasks` explicitly would
|
|
852
|
+
have to give up the inference that makes `NodesOf<>` and every
|
|
853
|
+
`edge(from, to)` check work. One or the other, never both.
|
|
854
|
+
|
|
855
|
+
So the pen ships both directions as separate spellings, and a caller who
|
|
856
|
+
wants a registry checked uses the first:
|
|
857
|
+
|
|
858
|
+
```ts
|
|
859
|
+
interface Registry { llm: (props: { with: unknown; input: unknown }, signal: AbortSignal) => unknown }
|
|
860
|
+
|
|
861
|
+
// direction 1 — annotate the node map: a task naming a handler the registry lacks
|
|
862
|
+
// fails AT THE NODE, which is where the typo is
|
|
863
|
+
const nodes = {
|
|
864
|
+
rows: input(),
|
|
865
|
+
ask: task('llm'),
|
|
866
|
+
out: output(),
|
|
867
|
+
} satisfies NodesFor<Registry>;
|
|
868
|
+
|
|
869
|
+
const graph = defineDag({ nodes, edges: [edge('rows', 'ask'), edge('ask', 'out')] });
|
|
870
|
+
// ^ still Dag<'rows' | 'ask' | 'out', 'llm'> — satisfies preserves the literal types
|
|
871
|
+
|
|
872
|
+
// direction 2 — an identity wrapper at the binding: one handler per declared task name
|
|
873
|
+
compileDag(graph, { tasks: typedTasks(graph, { llm: askTheModel }) });
|
|
874
|
+
// @ts-expect-error — the graph declares 'llm', not 'other'
|
|
875
|
+
void typedTasks(graph, { other: async () => null });
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
`satisfies` rather than a type annotation is what makes direction 1 work:
|
|
879
|
+
an annotation (`const nodes: NodesFor<Registry> = { … }`) would WIDEN the
|
|
880
|
+
node map to the annotated type and destroy the literal ids, so
|
|
881
|
+
`edge('rows', 'ask')` would stop being checked. `satisfies` checks the
|
|
882
|
+
value against the type and keeps the value's own inferred type, which is
|
|
883
|
+
exactly the trade this needs.
|
|
884
|
+
|
|
885
|
+
The same wall stands in front of `defineFsm<Ctx>` for the same reason,
|
|
886
|
+
and §5.1's annotation table is the answer there. The runtime check for a
|
|
887
|
+
missing handler remains the engine's `JF0018`, at `compileDag` — early,
|
|
888
|
+
not mid-run.
|
|
889
|
+
|
|
890
|
+
### 5.4 What the pins hold
|
|
891
|
+
|
|
892
|
+
`test/consumer/linq-flow.ts` (119 lines) is the compile-level record. It
|
|
893
|
+
pins, with `Equals<A, B>` — identity in both directions, never
|
|
894
|
+
assignability — `StatesOf<>`, `EventsOf<>` and `ContextOf<>` of a machine
|
|
895
|
+
with a mixed `states` array, a `payload`-declared guard, an annotated
|
|
896
|
+
effect and a wildcard; the `never` event union of a machine whose only
|
|
897
|
+
transition is a wildcard; and `NodesOf<>` and `TasksOf<>` of a six-node
|
|
898
|
+
graph. It pins the two type-level directions of the registry check and
|
|
899
|
+
nine `@ts-expect-error` negatives, each of which FAILS the build if it
|
|
900
|
+
ever starts compiling: an undeclared `to`, an unfinished transition, an
|
|
901
|
+
undeclared `initial`, an unknown `defineFsm` member, an undeclared edge
|
|
902
|
+
end in each direction, an unknown `defineDag` member, a registry naming a
|
|
903
|
+
handler the graph does not declare, and a `task()` naming a handler the
|
|
904
|
+
registry does not provide.
|
|
905
|
+
|
|
906
|
+
## 6. What it cannot spell
|
|
907
|
+
|
|
908
|
+
### 6.1 A display guard that decides execution
|
|
909
|
+
|
|
910
|
+
§4.2's refusal restated as a limit, because it is one. The format has
|
|
911
|
+
exactly one guard vocabulary and it is executable: a `$`-rooted path or
|
|
912
|
+
an operator document. There is no way to carry a human-readable condition
|
|
913
|
+
alongside — `[count > 3]` from a diagram label — and have it BE the
|
|
914
|
+
condition, and the format's decision (a non-`$` literal is vacuously
|
|
915
|
+
true) means the closest thing to it is a guard that always passes.
|
|
916
|
+
|
|
917
|
+
The alternative is to carry both: the executable guard in `guard`, and
|
|
918
|
+
the display text wherever the projection wants it. That is what the
|
|
919
|
+
mermaid projection does in the other direction, and it is why the two
|
|
920
|
+
documents can round-trip at all.
|
|
921
|
+
|
|
922
|
+
### 6.2 There is no workflow pen, and there will not be one
|
|
923
|
+
|
|
924
|
+
`jaren-workflow` is what `@jarenjs/mermaid`'s state-diagram parser
|
|
925
|
+
produces from a diagram: no `$fsm` key, string states, null events and
|
|
926
|
+
null guards. It is a PROJECTION of a machine, and the binder's "no pen,
|
|
927
|
+
by decision" table ([LINQ-FORMAT.md](LINQ-FORMAT.md) §1.0) says why it
|
|
928
|
+
gets no pen of its own — the fsm pen's documents are its executable
|
|
929
|
+
superset.
|
|
930
|
+
|
|
931
|
+
The practical consequence, and it is a good one: a machine written with
|
|
932
|
+
string states and no guards through `defineFsm()` validates under
|
|
933
|
+
`jaren-workflow` as well as under `jaren-fsm`, so a pen-written machine
|
|
934
|
+
is projectable to a diagram without a conversion step, and a
|
|
935
|
+
diagram-parsed workflow compiles with `compileFsm` unchanged.
|
|
936
|
+
`test/linq/flow-pen.test.js` validates a pen document under both
|
|
937
|
+
grammars.
|
|
938
|
+
|
|
939
|
+
### 6.3 The dataflow's non-goals, which are the format's
|
|
940
|
+
|
|
941
|
+
Named here so a reader does not read absence as an oversight, each with
|
|
942
|
+
the section that owns it. A run is one value in and one value out —
|
|
943
|
+
streaming a graph chunk by chunk changes the node contract and is a
|
|
944
|
+
format revision (§7.5). A machine has no hierarchy, no history states, no
|
|
945
|
+
parallel regions and no delayed or timed transitions (§1.1). A machine's
|
|
946
|
+
current state is a string and storing it is the host's business (§1.1);
|
|
947
|
+
`createFsmSession` is a convenience over that, not persistence. And an
|
|
948
|
+
effect is never invoked by the engine: the descriptors come back as data
|
|
949
|
+
and the host's registry runs them. Both engines' remaining non-goals are
|
|
950
|
+
on [docs/ROADMAP.md](../../../docs/ROADMAP.md) under `@jarenjs/flow`, and
|
|
951
|
+
none of them is a pen limitation.
|
|
952
|
+
|
|
953
|
+
### 6.4 The shape a `query` node's result takes
|
|
954
|
+
|
|
955
|
+
§3.5's singleton rule is a limit a writer has to plan around today, and
|
|
956
|
+
it belongs in this list even though it is neither a refusal nor a pen
|
|
957
|
+
decision: a `query` node's result is a value, an array or `null`
|
|
958
|
+
depending on how many items the query yielded, and the document cannot
|
|
959
|
+
say which it wants. Until the format answers, the spelling that has one
|
|
960
|
+
meaning is a `$return` that constructs an array explicitly.
|
|
961
|
+
|
|
962
|
+
### 6.5 When not to reach for this pen
|
|
963
|
+
|
|
964
|
+
- **The machine or the graph is data.** A `jaren-fsm` or `jaren-dag`
|
|
965
|
+
document read from a file, drawn in the studio, or projected from
|
|
966
|
+
mermaid is a value; `compileFsm`/`compileDag` take it directly.
|
|
967
|
+
- **The control flow is a function.** A pipeline that runs in one
|
|
968
|
+
process, never crosses a boundary and is never drawn is three
|
|
969
|
+
`await`s. A dataflow document buys checkpointing, a diagram, a task
|
|
970
|
+
registry a host substitutes and a document a test can assert — pay for
|
|
971
|
+
it when you want one of those.
|
|
972
|
+
- **The states are not a closed set.** `defineFsm` types state ids as
|
|
973
|
+
literals, which is most of what it buys you; a machine whose states are
|
|
974
|
+
computed, loaded, or numerous enough that nobody would type them is
|
|
975
|
+
better as data.
|
|
976
|
+
- **The work is long-running and needs to survive a restart.** §6.2 says
|
|
977
|
+
it plainly: there is no workflow pen and there will not be one.
|
|
978
|
+
Durability, retries, timers and compensation belong to a workflow
|
|
979
|
+
engine, and a dataflow document that grew them would be one wearing the
|
|
980
|
+
wrong name.
|
|
981
|
+
- **A guard needs to ask something the query language cannot.** Guards
|
|
982
|
+
evaluate over the step scope with one `$` and no externals (§4.4), so a
|
|
983
|
+
clock, a lookup, or a call into a service has no spelling. Decide it in
|
|
984
|
+
the host and send a different EVENT — which is what an event is for.
|
|
985
|
+
|
|
986
|
+
## 7. Cost
|
|
987
|
+
|
|
988
|
+
`@jarenjs/linq/flow` builds to **<!--fact:bundle.flow-->19,910<!--/fact--> bytes** as a minified,
|
|
989
|
+
tree-shaken ESM bundle — the figure `scripts/check-tree-shaking.js`
|
|
990
|
+
measures and `npm run test:tree-shaking` reports, published rounded
|
|
991
|
+
(<!--fact:bundle.flow.kb-->20<!--/fact--> kB) beside the other nine subpath prices in
|
|
992
|
+
[docs/CONSUMING.md](../../../docs/CONSUMING.md).
|
|
993
|
+
|
|
994
|
+
The probe is a gate, not a report. Building a machine with a guard and
|
|
995
|
+
two effects as a consumer would — `defineFsm`, `state`, `on` and
|
|
996
|
+
`effect` — it asserts that the bundle carries:
|
|
997
|
+
|
|
998
|
+
- **no `@jarenjs/flow` byte** — neither compiler, no `fsmToApp`, no
|
|
999
|
+
session. A consumer who only WRITES documents — a build script, a CLI
|
|
1000
|
+
that emits a graph, a test fixture — ships none of the engine;
|
|
1001
|
+
- **no other engine** — nothing of `@jarenjs/json`, `@jarenjs/validate`,
|
|
1002
|
+
`@jarenjs/emit`, `@jarenjs/db`, `@jarenjs/formats`, `@jarenjs/refs` or
|
|
1003
|
+
`@jarenjs/contract`;
|
|
1004
|
+
- **no chain module** — none of `sequence.js`, `document.js`, `async.js`,
|
|
1005
|
+
`concurrency.js`, `provider.js`, `sources.js` or `schema-of.js`;
|
|
1006
|
+
- **of the schema pen, only `brand.js`** — the builder brand, which
|
|
1007
|
+
`on()` and `defineFsm()` need to tell a `payload`/`context` builder
|
|
1008
|
+
from a hand-written object;
|
|
1009
|
+
- **no other pen** — not one byte of `model`, `jslt`, `migration`,
|
|
1010
|
+
`contract`, `app`, `forms` or `db`, which is what §2's `jslt()` row
|
|
1011
|
+
means when it says the stylesheet arrives as a document: the node takes
|
|
1012
|
+
JSON and never imports the pen that wrote it;
|
|
1013
|
+
- **a ceiling** of 20,000 bytes; and the other direction, that neither the
|
|
1014
|
+
chain's bundle nor the schema pen's carries a byte of
|
|
1015
|
+
`packages/linq/src/flow/`.
|
|
1016
|
+
|
|
1017
|
+
Two documents, two grammars, thirteen exported names — and 57 bytes more
|
|
1018
|
+
than `./jslt`'s <!--fact:bundle.jslt-->19,856<!--/fact-->, which writes one. The reason is that most of
|
|
1019
|
+
both prices is the same shared machinery: the recording proxy
|
|
1020
|
+
(`expression.js`), the root capture (`capture-root.js`) and the JSON
|
|
1021
|
+
boundary (`json-boundary.js`). What this pen adds on top of them is 685
|
|
1022
|
+
lines of member checks and the messages §4 quotes — and, as the model
|
|
1023
|
+
pen's own §7 notes, the message text is most of what a mirrored rule
|
|
1024
|
+
costs.
|
|
1025
|
+
|
|
1026
|
+
What a consumer actually pays for §3.6's graph is both subpaths, since
|
|
1027
|
+
the stylesheet has to be written by something — but not the sum: the
|
|
1028
|
+
recording proxy, the root capture and the JSON boundary are shared, so
|
|
1029
|
+
the second subpath adds only its own files. Every figure on this page is
|
|
1030
|
+
measured rather than typed: the probe compares `docs/CONSUMING.md`'s ten
|
|
1031
|
+
rounded prices AND every pen document's exact §7 byte count to the bundle
|
|
1032
|
+
it just built, so a stale number is a red gate rather than a wrong
|
|
1033
|
+
sentence.
|