@jarenjs/linq 0.46.5 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +217 -0
- package/README.md +566 -17
- package/docs/APP-PEN.md +1143 -0
- package/docs/CONTRACT-PEN.md +1217 -0
- package/docs/DB-CLIENT.md +814 -0
- package/docs/FLOW-PEN.md +1026 -0
- package/docs/FORMS-PEN.md +940 -0
- package/docs/JSLT-PEN.md +955 -0
- package/docs/LINQ-FORMAT.md +774 -384
- package/docs/MIGRATION-PEN.md +781 -0
- package/docs/MODEL-PEN.md +1083 -0
- package/docs/QUERY-PEN.md +1636 -0
- package/docs/SCHEMA-PEN.md +1218 -0
- package/package.json +57 -4
- package/src/app/action.js +255 -0
- package/src/app/capture.js +63 -0
- package/src/app/define.js +260 -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 +329 -75
- package/src/capture-root.js +82 -0
- package/src/concurrency.js +9 -4
- package/src/contract/define.js +269 -0
- package/src/contract/http.js +247 -0
- package/src/contract/index.js +23 -0
- package/src/contract/operation.js +342 -0
- package/src/db/handle.js +86 -0
- package/src/db/include.js +316 -0
- package/src/db/index.js +19 -0
- package/src/db/live.js +43 -0
- package/src/db/membership.js +37 -0
- package/src/db/open.js +82 -0
- package/src/document.js +143 -13
- package/src/effect.js +65 -0
- package/src/errors.js +69 -6
- package/src/expression.js +532 -39
- package/src/flow/capture.js +33 -0
- package/src/flow/dag.js +302 -0
- package/src/flow/fsm.js +328 -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 +4 -2
- package/src/jslt/body.js +226 -0
- package/src/jslt/index.js +18 -0
- package/src/jslt/rules.js +207 -0
- package/src/json-boundary.js +90 -0
- package/src/migration/define.js +323 -0
- package/src/migration/index.js +15 -0
- package/src/migration/steps.js +248 -0
- package/src/model/collection.js +171 -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 +371 -0
- package/types/db.d.ts +188 -0
- package/types/flow.d.ts +285 -0
- package/types/forms.d.ts +253 -0
- package/types/index.d.ts +389 -41
- package/types/jslt.d.ts +193 -0
- package/types/migration.d.ts +201 -0
- package/types/model.d.ts +493 -0
- package/types/schema.d.ts +494 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The one capture under every query-valued member of the flow
|
|
4
|
+
* documents: a transition guard and an effect's `with` over
|
|
5
|
+
* FLOW-FORMAT §3's step scope, a `query` node's document, a task's
|
|
6
|
+
* `with` and an edge's `select` over §6.1's input scope.
|
|
7
|
+
*
|
|
8
|
+
* The scope binds NO externals — both engines evaluate these with a
|
|
9
|
+
* single `$` and nothing else — so a name read off the capture's second
|
|
10
|
+
* argument is `JL0104` here, where the fix can be named, rather than
|
|
11
|
+
* `JQ2006` at step time, where a guard that cannot bind reads as a
|
|
12
|
+
* recorded false (FLOW-FORMAT §5.2). A returned literal is spelled as
|
|
13
|
+
* the format's own constructor and never folded into `$const`:
|
|
14
|
+
* §2's `{ "text": "retrying" }` is what a machine document carries.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { cloneJson } from '@jarenjs/core/object';
|
|
18
|
+
import { captureQuery } from '../capture-root.js';
|
|
19
|
+
import { requireJson } from '../json-boundary.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One query-valued member: a callback captured over `$`, or a query
|
|
23
|
+
* document written by hand, copied.
|
|
24
|
+
* @param {string} what - the method, for the message
|
|
25
|
+
* @param {string} scope - what `$` is bound to, for the `JL0104` advice
|
|
26
|
+
* @param {any} value - a callback `(s) => …`, or a query document
|
|
27
|
+
* @returns {any} the query document (plain JSON)
|
|
28
|
+
*/
|
|
29
|
+
export function queryMember(what, scope, value) {
|
|
30
|
+
if (typeof value !== 'function') return cloneJson(requireJson(value, what));
|
|
31
|
+
const advice = () => ` — ${what} evaluates over ${scope}, which its argument IS`;
|
|
32
|
+
return cloneJson(captureQuery(what, [], value, { advice, fold: false, noun: 'callback' }));
|
|
33
|
+
}
|
package/src/flow/dag.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `defineDag()` — one `jaren-dag` 0.1 document (FLOW-FORMAT.md
|
|
4
|
+
* §6), deep-frozen, that `compileDag` takes unchanged. `input()`,
|
|
5
|
+
* `constant()`, `query()`, `jslt()`, `task()` and `output()` are the
|
|
6
|
+
* closed kind vocabulary §6 fixes — one pen method per kind, so a kind
|
|
7
|
+
* the format does not have cannot be spelled — and `edge()` is the
|
|
8
|
+
* wiring. `.checkpoint()` writes §7.6's opt-in `checkpoint: true`.
|
|
9
|
+
*
|
|
10
|
+
* A `query` node's document, a `task`'s props and an edge's `select`
|
|
11
|
+
* are captured over the node's input scope (§6.1) at `$`; a `jslt`
|
|
12
|
+
* node's stylesheet is the JSLT pen's document, or one written by hand.
|
|
13
|
+
* Node ids are literal types, so an edge from a node nothing declares
|
|
14
|
+
* is a compile error; at runtime it is `JL0102` naming the id, before
|
|
15
|
+
* the compiler's `JF0013`.
|
|
16
|
+
*
|
|
17
|
+
* What the pen does NOT judge is the compiler's, and every emitted
|
|
18
|
+
* document is compiled in the tests: the wiring rules (`JF0015`),
|
|
19
|
+
* acyclicity (`JF0016`), the exactly-one-output rule (`JF0017`) and
|
|
20
|
+
* task-registry resolution (`JF0018`).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { cloneJson, deepFreeze, setObjectMember } from '@jarenjs/core/object';
|
|
24
|
+
|
|
25
|
+
import { LinqBuildError } from '../errors.js';
|
|
26
|
+
import { describeValue, requireJson, requireNameMap } from '../json-boundary.js';
|
|
27
|
+
import { queryMember } from './capture.js';
|
|
28
|
+
|
|
29
|
+
const DAG_VERSION = '0.1';
|
|
30
|
+
|
|
31
|
+
/** FLOW-FORMAT §6.1's scope, for a `JL0104` message. */
|
|
32
|
+
const SCOPE = "the node's input scope (FLOW-FORMAT §6.1)";
|
|
33
|
+
|
|
34
|
+
/** The node-declaration brand; the members it emits live under it. */
|
|
35
|
+
const NODE = Symbol.for('@jarenjs/linq/flow-node');
|
|
36
|
+
/** The edge brand; the members it emits live under it. */
|
|
37
|
+
const EDGE = Symbol.for('@jarenjs/linq/flow-edge');
|
|
38
|
+
|
|
39
|
+
/** The members `edge()` takes beside its two positional arguments. */
|
|
40
|
+
const EDGE_MEMBERS = Object.freeze(['port', 'select']);
|
|
41
|
+
/** The members `defineDag()` takes. */
|
|
42
|
+
const DAG_MEMBERS = Object.freeze(['nodes', 'edges']);
|
|
43
|
+
|
|
44
|
+
/** @param {any} value */
|
|
45
|
+
function isPlainObject(value) {
|
|
46
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A member set the pen knows, or `JL0101` naming the one it does not.
|
|
51
|
+
* @param {any} spec
|
|
52
|
+
* @param {readonly string[]} members
|
|
53
|
+
* @param {string} what
|
|
54
|
+
*/
|
|
55
|
+
function closedTo(spec, members, what) {
|
|
56
|
+
for (const key of Object.keys(spec)) {
|
|
57
|
+
if (!members.includes(key)) {
|
|
58
|
+
throw new LinqBuildError('JL0101',
|
|
59
|
+
`${what} does not take '${key}' — it takes ${members.join(', ')}`, `/${key}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One node declaration: immutable, its emitted members carried under
|
|
66
|
+
* the brand so `checkpoint` can be a method here and a member there.
|
|
67
|
+
* @param {any} members - the node's members, in document order
|
|
68
|
+
* @returns {any}
|
|
69
|
+
*/
|
|
70
|
+
function node(members) {
|
|
71
|
+
const out = {
|
|
72
|
+
/**
|
|
73
|
+
* Declare this node's value durable (§7.6): a checkpoint store
|
|
74
|
+
* records it and a resumed run seeds it instead of re-evaluating.
|
|
75
|
+
* The value must be JSON — `JF2008` at save time otherwise, never
|
|
76
|
+
* a silent skip.
|
|
77
|
+
*/
|
|
78
|
+
checkpoint() { return node({ ...members, checkpoint: true }); },
|
|
79
|
+
};
|
|
80
|
+
Object.defineProperty(out, NODE, { value: members, enumerable: false });
|
|
81
|
+
return Object.freeze(out);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The `input` node (§6): it yields the `run(input)` value — `null` when
|
|
86
|
+
* the caller passes none — and accepts no inbound edge.
|
|
87
|
+
* @returns {any} the node declaration
|
|
88
|
+
*/
|
|
89
|
+
export function input() { return node({ kind: 'input' }); }
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The `output` node (§6): its input-scope value IS the run's result.
|
|
93
|
+
* Exactly one per document (the compiler's `JF0017`), no outbound edge.
|
|
94
|
+
* @returns {any} the node declaration
|
|
95
|
+
*/
|
|
96
|
+
export function output() { return node({ kind: 'output' }); }
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A `const` node (§6): its literal value, delivered by reference.
|
|
100
|
+
* @param {any} value - any JSON value
|
|
101
|
+
* @returns {any} the node declaration
|
|
102
|
+
* @example
|
|
103
|
+
* constant({ threshold: 18 });
|
|
104
|
+
*/
|
|
105
|
+
export function constant(value) {
|
|
106
|
+
if (value === undefined) {
|
|
107
|
+
throw new LinqBuildError('JL0101',
|
|
108
|
+
'constant() takes the value the node yields — every JSON value, null included; '
|
|
109
|
+
+ 'undefined is not one', '/value');
|
|
110
|
+
}
|
|
111
|
+
return node({ kind: 'const', value: cloneJson(requireJson(value, 'constant()')) });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A `query` node (§6): a Jaren JSON Query over the node's input scope.
|
|
116
|
+
* @param {any} document - `(v) => …` captured over `$`, or a query document
|
|
117
|
+
* @returns {any} the node declaration
|
|
118
|
+
* @example
|
|
119
|
+
* query((rows) => rows.all().age); // captured
|
|
120
|
+
* query('$.summary'); // a path document, verbatim
|
|
121
|
+
*/
|
|
122
|
+
export function query(document) {
|
|
123
|
+
if (document === undefined) {
|
|
124
|
+
throw new LinqBuildError('JL0101',
|
|
125
|
+
'query() takes a callback (v) => … captured over the node input, or a query document',
|
|
126
|
+
'/query');
|
|
127
|
+
}
|
|
128
|
+
return node({ kind: 'query', query: queryMember('query()', SCOPE, document) });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A `jslt` node (§6): a stylesheet transforming the node's input scope.
|
|
133
|
+
* @param {any} document - the JSLT pen's stylesheet (or rule array), or one by hand
|
|
134
|
+
* @returns {any} the node declaration
|
|
135
|
+
* @example
|
|
136
|
+
* jslt(stylesheet([rule('$', (v) => ({ names: [apply(v.all())] }))]));
|
|
137
|
+
*/
|
|
138
|
+
export function jslt(document) {
|
|
139
|
+
if (document === undefined) {
|
|
140
|
+
throw new LinqBuildError('JL0101',
|
|
141
|
+
'jslt() takes a stylesheet document — the JSLT pen\'s stylesheet(…) or rule array, '
|
|
142
|
+
+ 'or one written by hand', '/stylesheet');
|
|
143
|
+
}
|
|
144
|
+
return node({ kind: 'jslt', stylesheet: cloneJson(requireJson(document, 'jslt()')) });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A `task` node (§7.2): a registered async handler, called as
|
|
149
|
+
* `handler({ with, input }, signal)`. The pen writes the NAME; the
|
|
150
|
+
* registry a host hands `compileDag` resolves it (`JF0018` when it
|
|
151
|
+
* cannot).
|
|
152
|
+
* @param {string} run - the registry handler name
|
|
153
|
+
* @param {any} [props] - `(v) => ({ … })` over the input scope, or a query document
|
|
154
|
+
* @returns {any} the node declaration
|
|
155
|
+
* @example
|
|
156
|
+
* task('llm', (v) => ({ prompt: v.instruction }));
|
|
157
|
+
*/
|
|
158
|
+
export function task(run, props = undefined) {
|
|
159
|
+
if (typeof run !== 'string' || run === '') {
|
|
160
|
+
throw new LinqBuildError('JL0101',
|
|
161
|
+
`task() takes the handler name as a non-empty string, got ${describeValue(run)}`, '/run');
|
|
162
|
+
}
|
|
163
|
+
const members = { kind: 'task', run };
|
|
164
|
+
if (props !== undefined) members.with = queryMember('task() with', SCOPE, props);
|
|
165
|
+
return node(members);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One edge (§6): data flows from a node's result to a consumer's input
|
|
170
|
+
* scope. `port` names the delivery in a ported fan-in (§6.1); `select`
|
|
171
|
+
* is applied to the source value before delivery.
|
|
172
|
+
* @param {string} from - the producing node id
|
|
173
|
+
* @param {string} to - the consuming node id
|
|
174
|
+
* @param {{ port?: string, select?: any }} [options]
|
|
175
|
+
* @returns {any} the edge declaration
|
|
176
|
+
* @example
|
|
177
|
+
* edge('rows', 'adults');
|
|
178
|
+
* edge('adults', 'report', { port: 'rows', select: (v) => v.all().name });
|
|
179
|
+
*/
|
|
180
|
+
export function edge(from, to, options = undefined) {
|
|
181
|
+
if (typeof from !== 'string' || from === '') {
|
|
182
|
+
throw new LinqBuildError('JL0101',
|
|
183
|
+
`edge() takes the producing node id as a non-empty string, got ${describeValue(from)}`,
|
|
184
|
+
'/from');
|
|
185
|
+
}
|
|
186
|
+
if (typeof to !== 'string' || to === '') {
|
|
187
|
+
throw new LinqBuildError('JL0101',
|
|
188
|
+
`edge() takes the consuming node id as a non-empty string, got ${describeValue(to)}`,
|
|
189
|
+
'/to');
|
|
190
|
+
}
|
|
191
|
+
const members = { from, to };
|
|
192
|
+
if (options !== undefined) {
|
|
193
|
+
if (!isPlainObject(options)) {
|
|
194
|
+
throw new LinqBuildError('JL0101',
|
|
195
|
+
`edge() options are { port?, select? }, got ${describeValue(options)}`);
|
|
196
|
+
}
|
|
197
|
+
closedTo(options, EDGE_MEMBERS, 'edge()');
|
|
198
|
+
if (options.port !== undefined) {
|
|
199
|
+
if (typeof options.port !== 'string' || options.port === '') {
|
|
200
|
+
throw new LinqBuildError('JL0101',
|
|
201
|
+
`edge() port is a non-empty string, got ${describeValue(options.port)}`, '/port');
|
|
202
|
+
}
|
|
203
|
+
members.port = options.port;
|
|
204
|
+
}
|
|
205
|
+
if (options.select !== undefined) {
|
|
206
|
+
members.select = queryMember('edge() select', 'the source value (FLOW-FORMAT §6)',
|
|
207
|
+
options.select);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const out = {};
|
|
211
|
+
Object.defineProperty(out, EDGE, { value: members, enumerable: false });
|
|
212
|
+
return Object.freeze(out);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Write a `jaren-dag` 0.1 document (FLOW-FORMAT.md §6).
|
|
217
|
+
*
|
|
218
|
+
* Every id an edge names must be declared — `JL0102` naming it, before
|
|
219
|
+
* the compiler's `JF0013`. Everything else about the wiring is
|
|
220
|
+
* `compileDag`'s.
|
|
221
|
+
*
|
|
222
|
+
* @param {any} spec - `{ nodes, edges }`
|
|
223
|
+
* @returns {any} the deep-frozen `$dag` 0.1 document
|
|
224
|
+
* @throws {LinqBuildError} `JL0101` a value the pen cannot spell;
|
|
225
|
+
* `JL0102` an edge on an undeclared node id
|
|
226
|
+
* @example
|
|
227
|
+
* const graph = defineDag({
|
|
228
|
+
* nodes: { rows: input(), adults: query('$[*]'), out: output() },
|
|
229
|
+
* edges: [edge('rows', 'adults'), edge('adults', 'out')],
|
|
230
|
+
* });
|
|
231
|
+
* await compileDag(graph).run([{ age: 20 }]);
|
|
232
|
+
*/
|
|
233
|
+
export function defineDag(spec) {
|
|
234
|
+
if (!isPlainObject(spec)) {
|
|
235
|
+
throw new LinqBuildError('JL0101',
|
|
236
|
+
`defineDag() takes { nodes, edges }, got ${describeValue(spec)}`);
|
|
237
|
+
}
|
|
238
|
+
closedTo(spec, DAG_MEMBERS, 'defineDag()');
|
|
239
|
+
if (!isPlainObject(spec.nodes)) {
|
|
240
|
+
throw new LinqBuildError('JL0101',
|
|
241
|
+
`defineDag() nodes is a plain object of id → node declaration, got ${describeValue(spec.nodes)}`,
|
|
242
|
+
'/nodes');
|
|
243
|
+
}
|
|
244
|
+
requireNameMap(spec.nodes, 'defineDag() nodes', '/nodes');
|
|
245
|
+
const ids = Object.keys(spec.nodes);
|
|
246
|
+
if (ids.length === 0) {
|
|
247
|
+
throw new LinqBuildError('JL0101', 'defineDag() needs at least one node', '/nodes');
|
|
248
|
+
}
|
|
249
|
+
const nodes = {};
|
|
250
|
+
for (const id of ids) {
|
|
251
|
+
const declared = spec.nodes[id];
|
|
252
|
+
const members = isPlainObject(declared) ? declared[NODE] : undefined;
|
|
253
|
+
if (members === undefined) {
|
|
254
|
+
throw new LinqBuildError('JL0101',
|
|
255
|
+
`defineDag() node '${id}' is input(), constant(), query(), jslt(), task() or `
|
|
256
|
+
+ `output(), got ${describeValue(declared)}`, `/nodes/${id}`);
|
|
257
|
+
}
|
|
258
|
+
setObjectMember(nodes, id, { ...members });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!Array.isArray(spec.edges)) {
|
|
262
|
+
throw new LinqBuildError('JL0101',
|
|
263
|
+
`defineDag() edges is an array of edge(from, to) declarations, got ${describeValue(spec.edges)}`,
|
|
264
|
+
'/edges');
|
|
265
|
+
}
|
|
266
|
+
const edges = spec.edges.map((declared, i) => {
|
|
267
|
+
const at = `/edges/${i}`;
|
|
268
|
+
const members = isPlainObject(declared) ? declared[EDGE] : undefined;
|
|
269
|
+
if (members === undefined) {
|
|
270
|
+
throw new LinqBuildError('JL0101',
|
|
271
|
+
`defineDag() edges[${i}] is edge(from, to, options?), got ${describeValue(declared)}`, at);
|
|
272
|
+
}
|
|
273
|
+
for (const end of ['from', 'to']) {
|
|
274
|
+
if (!Object.hasOwn(nodes, members[end])) {
|
|
275
|
+
throw new LinqBuildError('JL0102',
|
|
276
|
+
`edge ${i} names the node '${members[end]}', which "nodes" does not declare — the `
|
|
277
|
+
+ `declared nodes are ${ids.map((id) => `'${id}'`).join(', ')}`, `${at}/${end}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return { ...members };
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
return deepFreeze({ $dag: DAG_VERSION, nodes, edges });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Bind a task registry to the graph it serves. Identity at runtime: the
|
|
288
|
+
* table is checked against the task names the document declares, so the
|
|
289
|
+
* registry `compileDag` resolves and the document agree at compile
|
|
290
|
+
* time; the runtime check remains `JF0018`.
|
|
291
|
+
* @template D
|
|
292
|
+
* @template T
|
|
293
|
+
* @param {D} dag - the pen's graph; the type argument only
|
|
294
|
+
* @param {T} tasks - handler name → handler
|
|
295
|
+
* @returns {T}
|
|
296
|
+
* @example
|
|
297
|
+
* compileDag(graph, { tasks: typedTasks(graph, { llm: askModel }) });
|
|
298
|
+
*/
|
|
299
|
+
export function typedTasks(dag, tasks) {
|
|
300
|
+
void dag;
|
|
301
|
+
return tasks;
|
|
302
|
+
}
|
package/src/flow/fsm.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `defineFsm()` — one `jaren-fsm` 0.1 document
|
|
4
|
+
* (FLOW-FORMAT.md §2), deep-frozen, that `compileFsm` and `fsmToApp`
|
|
5
|
+
* take unchanged. `state()` declares one state, `on()` one transition
|
|
6
|
+
* (`.when()` its guard, `.to()` its target, `.effects()` its
|
|
7
|
+
* descriptors) and `effect()` one `{ run, with? }`.
|
|
8
|
+
*
|
|
9
|
+
* Guards and `with` members are captured over FLOW-FORMAT §3's scope —
|
|
10
|
+
* `{ state, event, payload, context }` at `$` — so `(s) =>
|
|
11
|
+
* s.payload.fresh` writes `"$.payload.fresh"` and `() => ({ text:
|
|
12
|
+
* 'retrying' })` writes the constructor the format's own example
|
|
13
|
+
* carries. The one trap the format names is refused here: a guard given
|
|
14
|
+
* as a plain STRING is `JL0102`, because §3 makes a non-`$` literal
|
|
15
|
+
* vacuously TRUE and a picture's display annotation must never decide
|
|
16
|
+
* execution.
|
|
17
|
+
*
|
|
18
|
+
* State ids are literal types, so a transition into an undeclared state
|
|
19
|
+
* is a compile error; at runtime it is `JL0102` naming the state,
|
|
20
|
+
* before the compiler's `JF0006`. What the pen does NOT judge is the
|
|
21
|
+
* compiler's: duplicate ids (`JF0003`), a guard's operators (`JF0007`)
|
|
22
|
+
* and everything else §5.1 lists.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { deepFreeze } from '@jarenjs/core/object';
|
|
26
|
+
|
|
27
|
+
import { LinqBuildError } from '../errors.js';
|
|
28
|
+
import { effectDescriptor, readEffects } from '../effect.js';
|
|
29
|
+
import { describeValue } from '../json-boundary.js';
|
|
30
|
+
import { isSchemaBuilder } from '../schema/brand.js';
|
|
31
|
+
import { queryMember } from './capture.js';
|
|
32
|
+
|
|
33
|
+
const FSM_VERSION = '0.1';
|
|
34
|
+
|
|
35
|
+
/** FLOW-FORMAT §3's scope, for a `JL0104` message. */
|
|
36
|
+
const SCOPE = 'the step scope { state, event, payload, context } (FLOW-FORMAT §3)';
|
|
37
|
+
|
|
38
|
+
/** The state-declaration brand: how `defineFsm` tells one apart. */
|
|
39
|
+
const STATE = Symbol.for('@jarenjs/linq/flow-state');
|
|
40
|
+
/** The transition brand; the entry it carries lives under it. */
|
|
41
|
+
const TRANSITION = Symbol.for('@jarenjs/linq/flow-transition');
|
|
42
|
+
|
|
43
|
+
/** The members `state()` takes, in the order §2 writes them. */
|
|
44
|
+
const STATE_MEMBERS = Object.freeze(['entry', 'exit', 'final']);
|
|
45
|
+
/** The members `on()` takes beside its two positional arguments. */
|
|
46
|
+
const ON_MEMBERS = Object.freeze(['payload']);
|
|
47
|
+
/** The members `defineFsm()` takes. */
|
|
48
|
+
const FSM_MEMBERS = Object.freeze(['initial', 'states', 'transitions', 'context']);
|
|
49
|
+
|
|
50
|
+
/** @param {any} value */
|
|
51
|
+
function isPlainObject(value) {
|
|
52
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A member set the pen knows, or `JL0101` naming the one it does not.
|
|
57
|
+
* @param {any} spec
|
|
58
|
+
* @param {readonly string[]} members
|
|
59
|
+
* @param {string} what
|
|
60
|
+
* @param {string} [at]
|
|
61
|
+
*/
|
|
62
|
+
function closedTo(spec, members, what, at = '') {
|
|
63
|
+
for (const key of Object.keys(spec)) {
|
|
64
|
+
if (!members.includes(key)) {
|
|
65
|
+
throw new LinqBuildError('JL0101',
|
|
66
|
+
`${what} does not take '${key}' — it takes ${members.join(', ')}`, `${at}/${key}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A state id: a non-empty string, or `JL0101`.
|
|
73
|
+
* @param {any} id
|
|
74
|
+
* @param {string} what
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
function readId(id, what) {
|
|
78
|
+
if (typeof id !== 'string' || id === '') {
|
|
79
|
+
throw new LinqBuildError('JL0101',
|
|
80
|
+
`${what} takes a state id — a non-empty string, got ${describeValue(id)}`);
|
|
81
|
+
}
|
|
82
|
+
return id;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One effect descriptor (§2): `{ run, with? }`, its props captured over
|
|
87
|
+
* the step scope. The engine never executes it — the host's registry
|
|
88
|
+
* does — so `run` is a name the pen writes and never resolves.
|
|
89
|
+
* @param {string} run - the host-registered handler name
|
|
90
|
+
* @param {any} [props] - `(s) => ({ … })`, or a query document
|
|
91
|
+
* @returns {any} the effect declaration
|
|
92
|
+
* @example
|
|
93
|
+
* effect('fetch', (s) => ({ url: s.context.url })); // { run, with }
|
|
94
|
+
* effect('toast', () => ({ text: 'retrying' })); // a constructor, not $const
|
|
95
|
+
*/
|
|
96
|
+
export function effect(run, props = undefined) {
|
|
97
|
+
return effectDescriptor(run, props, (p) => queryMember('effect() with', SCOPE, p));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One state declaration (§2): `{ id, entry?, exit?, final? }`. A bare
|
|
102
|
+
* string in `states` is the format's own shorthand for `{ id }` and
|
|
103
|
+
* stays one — it is the shape the `jaren-workflow` projection carries.
|
|
104
|
+
* @param {string} id - the state id transitions refer to
|
|
105
|
+
* @param {{ entry?: readonly any[], exit?: readonly any[], final?: boolean }} [options]
|
|
106
|
+
* @returns {any} the state declaration
|
|
107
|
+
* @example
|
|
108
|
+
* state('loading', { entry: [effect('fetch', (s) => ({ url: s.context.url }))] });
|
|
109
|
+
* state('done', { final: true });
|
|
110
|
+
*/
|
|
111
|
+
export function state(id, options = undefined) {
|
|
112
|
+
const out = { id: readId(id, 'state()') };
|
|
113
|
+
if (options !== undefined) {
|
|
114
|
+
if (!isPlainObject(options)) {
|
|
115
|
+
throw new LinqBuildError('JL0101',
|
|
116
|
+
`state() options are { entry?, exit?, final? }, got ${describeValue(options)}`);
|
|
117
|
+
}
|
|
118
|
+
closedTo(options, STATE_MEMBERS, 'state()');
|
|
119
|
+
if (options.entry !== undefined) out.entry = readEffects(options.entry, 'state() entry');
|
|
120
|
+
if (options.exit !== undefined) out.exit = readEffects(options.exit, 'state() exit');
|
|
121
|
+
if (options.final !== undefined) {
|
|
122
|
+
if (typeof options.final !== 'boolean') {
|
|
123
|
+
throw new LinqBuildError('JL0101',
|
|
124
|
+
`state() final is a boolean, got ${describeValue(options.final)}`, '/final');
|
|
125
|
+
}
|
|
126
|
+
out.final = options.final;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
Object.defineProperty(out, STATE, { value: true, enumerable: false });
|
|
130
|
+
return Object.freeze(out);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A guard, as the document carries it: a captured body, or a query
|
|
135
|
+
* document by hand. A plain STRING is the one trap FLOW-FORMAT §3 names
|
|
136
|
+
* itself, and the pen refuses it.
|
|
137
|
+
* @param {any} value
|
|
138
|
+
* @returns {any}
|
|
139
|
+
*/
|
|
140
|
+
function readGuard(value) {
|
|
141
|
+
if (typeof value === 'string') {
|
|
142
|
+
throw new LinqBuildError('JL0102',
|
|
143
|
+
'a guard given as a plain string is asserted by effective boolean value, and a '
|
|
144
|
+
+ 'non-empty literal is therefore VACUOUSLY TRUE (FLOW-FORMAT §3: a projected '
|
|
145
|
+
+ `display guard must not change execution) — got ${JSON.stringify(value)}; pass a `
|
|
146
|
+
+ 'body instead: .when((s) => s.payload.fresh)', '/guard');
|
|
147
|
+
}
|
|
148
|
+
return queryMember('when()', SCOPE, value);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The transition builder: immutable, one new builder per call, its
|
|
153
|
+
* entry carried under the brand.
|
|
154
|
+
* @param {any} entry
|
|
155
|
+
* @returns {any}
|
|
156
|
+
*/
|
|
157
|
+
function transition(entry) {
|
|
158
|
+
const out = {
|
|
159
|
+
/**
|
|
160
|
+
* The transition's guard (§3), captured over the step scope.
|
|
161
|
+
* @param {any} guard - `(s) => …`, or a query document
|
|
162
|
+
*/
|
|
163
|
+
when(guard) { return transition({ ...entry, guard: readGuard(guard) }); },
|
|
164
|
+
/**
|
|
165
|
+
* The state this transition enters.
|
|
166
|
+
* @param {string} to
|
|
167
|
+
*/
|
|
168
|
+
to(to) { return transition({ ...entry, to: readId(to, 'to()') }); },
|
|
169
|
+
/**
|
|
170
|
+
* The transition's own effects, fired between exit and entry (§4).
|
|
171
|
+
* @param {readonly any[]} effects
|
|
172
|
+
*/
|
|
173
|
+
effects(effects) {
|
|
174
|
+
return transition({ ...entry, effects: readEffects(effects, 'effects()') });
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
Object.defineProperty(out, TRANSITION, { value: entry, enumerable: false });
|
|
178
|
+
return Object.freeze(out);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* One transition (§2), left open until `.to()` names its target.
|
|
183
|
+
* Document order is the whole priority scheme (§4), so the order of the
|
|
184
|
+
* `transitions` array is the order the pen writes.
|
|
185
|
+
* @param {string} from - the state this transition leaves
|
|
186
|
+
* @param {string|null} [event] - the event name; null or absent is a wildcard
|
|
187
|
+
* @param {{ payload?: any }} [options] - the event's payload schema; a
|
|
188
|
+
* TYPE only, the format carries no payload schema
|
|
189
|
+
* @returns {any} the transition builder
|
|
190
|
+
* @example
|
|
191
|
+
* on('draft', 'submit').to('review');
|
|
192
|
+
* on('review', 'approve').when((s) => s.payload.fresh).to('published');
|
|
193
|
+
* on('review').to('draft'); // a wildcard, listed last
|
|
194
|
+
*/
|
|
195
|
+
export function on(from, event = null, options = undefined) {
|
|
196
|
+
const entry = { from: readId(from, 'on()') };
|
|
197
|
+
if (event !== null && event !== undefined) {
|
|
198
|
+
if (typeof event !== 'string' || event === '') {
|
|
199
|
+
throw new LinqBuildError('JL0101',
|
|
200
|
+
'on() takes an event name as a non-empty string, or null for the wildcard that '
|
|
201
|
+
+ `matches any event (FLOW-FORMAT §2), got ${describeValue(event)}`, '/event');
|
|
202
|
+
}
|
|
203
|
+
entry.event = event;
|
|
204
|
+
}
|
|
205
|
+
if (options !== undefined) {
|
|
206
|
+
if (!isPlainObject(options)) {
|
|
207
|
+
throw new LinqBuildError('JL0101',
|
|
208
|
+
`on() options are { payload? }, got ${describeValue(options)}`);
|
|
209
|
+
}
|
|
210
|
+
closedTo(options, ON_MEMBERS, 'on()');
|
|
211
|
+
if (options.payload !== undefined && !isSchemaBuilder(options.payload)) {
|
|
212
|
+
throw new LinqBuildError('JL0101',
|
|
213
|
+
'on() payload is a schema-pen builder that types the event\'s payload — the format '
|
|
214
|
+
+ `carries no payload schema, so nothing is emitted for it; got ${describeValue(options.payload)}`,
|
|
215
|
+
'/payload');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return transition(entry);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Write a `jaren-fsm` 0.1 document (FLOW-FORMAT.md §2).
|
|
223
|
+
*
|
|
224
|
+
* `initial` is declared, never guessed: the format requires the member
|
|
225
|
+
* and `null` is what a document that chooses no start state writes.
|
|
226
|
+
* Every state id `initial` and a transition name must be declared —
|
|
227
|
+
* `JL0102` naming it, before the compiler's `JF0004`/`JF0006`.
|
|
228
|
+
*
|
|
229
|
+
* @param {any} spec - `{ initial, states, transitions, context? }`
|
|
230
|
+
* @returns {any} the deep-frozen `$fsm` 0.1 document
|
|
231
|
+
* @throws {LinqBuildError} `JL0101` a value the pen cannot spell;
|
|
232
|
+
* `JL0102` a plain-string guard, or an undeclared state id
|
|
233
|
+
* @example
|
|
234
|
+
* const machine = defineFsm({
|
|
235
|
+
* initial: 'draft',
|
|
236
|
+
* states: ['draft', 'review', state('published', { final: true })],
|
|
237
|
+
* transitions: [on('draft', 'submit').to('review'), on('review', 'approve').to('published')],
|
|
238
|
+
* });
|
|
239
|
+
* compileFsm(machine).step('draft', 'submit').state; // 'review'
|
|
240
|
+
*/
|
|
241
|
+
export function defineFsm(spec) {
|
|
242
|
+
if (!isPlainObject(spec)) {
|
|
243
|
+
throw new LinqBuildError('JL0101',
|
|
244
|
+
`defineFsm() takes { initial, states, transitions, context? }, got ${describeValue(spec)}`);
|
|
245
|
+
}
|
|
246
|
+
closedTo(spec, FSM_MEMBERS, 'defineFsm()');
|
|
247
|
+
if (spec.context !== undefined && !isSchemaBuilder(spec.context)) {
|
|
248
|
+
throw new LinqBuildError('JL0101',
|
|
249
|
+
'defineFsm() context is a schema-pen builder that types the host data a guard reads '
|
|
250
|
+
+ '(FLOW-FORMAT §3) — the format carries no context schema, so nothing is emitted for '
|
|
251
|
+
+ `it; got ${describeValue(spec.context)}`, '/context');
|
|
252
|
+
}
|
|
253
|
+
if (!Array.isArray(spec.states)) {
|
|
254
|
+
throw new LinqBuildError('JL0101',
|
|
255
|
+
`defineFsm() states is an array of ids and state() declarations, got ${describeValue(spec.states)}`,
|
|
256
|
+
'/states');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** @type {any[]} */
|
|
260
|
+
const states = [];
|
|
261
|
+
/** @type {Set<string>} */
|
|
262
|
+
const declared = new Set();
|
|
263
|
+
spec.states.forEach((entry, i) => {
|
|
264
|
+
if (typeof entry === 'string') {
|
|
265
|
+
declared.add(readId(entry, 'defineFsm() states'));
|
|
266
|
+
states.push(entry);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (!isPlainObject(entry) || entry[STATE] !== true) {
|
|
270
|
+
throw new LinqBuildError('JL0101',
|
|
271
|
+
`defineFsm() states[${i}] is an id or state(id, options?), got ${describeValue(entry)}`,
|
|
272
|
+
`/states/${i}`);
|
|
273
|
+
}
|
|
274
|
+
declared.add(entry.id);
|
|
275
|
+
states.push({ ...entry });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* @param {any} id
|
|
280
|
+
* @param {string} what
|
|
281
|
+
* @param {string} at
|
|
282
|
+
*/
|
|
283
|
+
const declaredId = (id, what, at) => {
|
|
284
|
+
if (!declared.has(id)) {
|
|
285
|
+
throw new LinqBuildError('JL0102',
|
|
286
|
+
`${what} names the state '${id}', which "states" does not declare — the declared `
|
|
287
|
+
+ `states are ${[...declared].map((s) => `'${s}'`).join(', ')}`, at);
|
|
288
|
+
}
|
|
289
|
+
return id;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
if (spec.initial === undefined) {
|
|
293
|
+
throw new LinqBuildError('JL0101',
|
|
294
|
+
'defineFsm() needs an initial state — the format requires the member; pass null for a '
|
|
295
|
+
+ 'machine that chooses none (a session then starts with an explicit state)', '/initial');
|
|
296
|
+
}
|
|
297
|
+
if (spec.initial !== null) {
|
|
298
|
+
declaredId(readId(spec.initial, 'defineFsm() initial'), 'defineFsm() initial', '/initial');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!Array.isArray(spec.transitions)) {
|
|
302
|
+
throw new LinqBuildError('JL0101',
|
|
303
|
+
`defineFsm() transitions is an array of on(…) declarations, got ${describeValue(spec.transitions)}`,
|
|
304
|
+
'/transitions');
|
|
305
|
+
}
|
|
306
|
+
const transitions = spec.transitions.map((declaration, i) => {
|
|
307
|
+
const at = `/transitions/${i}`;
|
|
308
|
+
const entry = isPlainObject(declaration) ? declaration[TRANSITION] : undefined;
|
|
309
|
+
if (entry === undefined) {
|
|
310
|
+
throw new LinqBuildError('JL0101',
|
|
311
|
+
`defineFsm() transitions[${i}] is on(from, event?).to(state), got `
|
|
312
|
+
+ `${describeValue(declaration)}`, at);
|
|
313
|
+
}
|
|
314
|
+
if (entry.to === undefined) {
|
|
315
|
+
throw new LinqBuildError('JL0101',
|
|
316
|
+
`defineFsm() transitions[${i}] never named its target — on('${entry.from}'`
|
|
317
|
+
+ `${entry.event === undefined ? '' : `, '${entry.event}'`}) needs .to(state)`, at);
|
|
318
|
+
}
|
|
319
|
+
const out = { from: declaredId(entry.from, `transition ${i}`, `${at}/from`) };
|
|
320
|
+
if (entry.event !== undefined) out.event = entry.event;
|
|
321
|
+
if (entry.guard !== undefined) out.guard = entry.guard;
|
|
322
|
+
out.to = declaredId(entry.to, `transition ${i}`, `${at}/to`);
|
|
323
|
+
if (entry.effects !== undefined) out.effects = entry.effects;
|
|
324
|
+
return out;
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
return deepFreeze({ $fsm: FSM_VERSION, initial: spec.initial, states, transitions });
|
|
328
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `@jarenjs/linq/flow` — the `jaren-fsm` and `jaren-dag` 0.1
|
|
4
|
+
* documents by code. `defineFsm()` writes the machine `compileFsm` and
|
|
5
|
+
* `fsmToApp` take; `defineDag()` writes the dataflow `compileDag`
|
|
6
|
+
* takes. Guards, effect props, node queries and edge selectors are
|
|
7
|
+
* callbacks captured over the scope the engine evaluates them in
|
|
8
|
+
* (FLOW-FORMAT §3 and §6.1), so a path is written, never typed as a
|
|
9
|
+
* string — which is also why a plain-string guard is refused here
|
|
10
|
+
* (§3 makes a literal one vacuously true).
|
|
11
|
+
*
|
|
12
|
+
* State ids, event names and node ids are literal types: a transition
|
|
13
|
+
* into an undeclared state or an edge from an undeclared node is a
|
|
14
|
+
* compile error before it is a `JL0102`, and long before the engine's
|
|
15
|
+
* `JF0006`/`JF0013`. The document is the deliverable — plain,
|
|
16
|
+
* deep-frozen JSON — and nothing here imports `@jarenjs/flow`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export { defineFsm, state, on, effect } from './fsm.js';
|
|
20
|
+
export {
|
|
21
|
+
defineDag, input, output, constant, query, jslt, task, edge, typedTasks,
|
|
22
|
+
} from './dag.js';
|