@jarenjs/linq 0.49.2 → 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 +559 -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 +771 -383
- 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 +437 -36
- 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 +231 -26
- 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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarenjs/linq",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.56.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"types": "./types/index.d.ts",
|
|
@@ -11,12 +11,49 @@
|
|
|
11
11
|
"types": "./types/index.d.ts",
|
|
12
12
|
"default": "./src/index.js"
|
|
13
13
|
},
|
|
14
|
+
"./schema": {
|
|
15
|
+
"types": "./types/schema.d.ts",
|
|
16
|
+
"default": "./src/schema/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./model": {
|
|
19
|
+
"types": "./types/model.d.ts",
|
|
20
|
+
"default": "./src/model/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./jslt": {
|
|
23
|
+
"types": "./types/jslt.d.ts",
|
|
24
|
+
"default": "./src/jslt/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./migration": {
|
|
27
|
+
"types": "./types/migration.d.ts",
|
|
28
|
+
"default": "./src/migration/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./contract": {
|
|
31
|
+
"types": "./types/contract.d.ts",
|
|
32
|
+
"default": "./src/contract/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./flow": {
|
|
35
|
+
"types": "./types/flow.d.ts",
|
|
36
|
+
"default": "./src/flow/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./app": {
|
|
39
|
+
"types": "./types/app.d.ts",
|
|
40
|
+
"default": "./src/app/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./forms": {
|
|
43
|
+
"types": "./types/forms.d.ts",
|
|
44
|
+
"default": "./src/forms/index.js"
|
|
45
|
+
},
|
|
46
|
+
"./db": {
|
|
47
|
+
"types": "./types/db.d.ts",
|
|
48
|
+
"default": "./src/db/index.js"
|
|
49
|
+
},
|
|
14
50
|
"./package.json": "./package.json"
|
|
15
51
|
},
|
|
16
52
|
"files": [
|
|
17
53
|
"types/",
|
|
18
54
|
"src/",
|
|
19
|
-
"docs/"
|
|
55
|
+
"docs/",
|
|
56
|
+
"ARCHITECTURE.md"
|
|
20
57
|
],
|
|
21
58
|
"description": "A C#-familiar fluent LINQ surface for the Jaren suite: expression capture into plain query documents, deferred immutable sequences, and a provider seam that runs the same document in memory or anywhere else",
|
|
22
59
|
"author": "joham",
|
|
@@ -47,7 +84,23 @@
|
|
|
47
84
|
"prepack": "npm run build:types"
|
|
48
85
|
},
|
|
49
86
|
"dependencies": {
|
|
50
|
-
"@jarenjs/core": "^0.
|
|
51
|
-
"@jarenjs/json": "^0.
|
|
87
|
+
"@jarenjs/core": "^0.56.0",
|
|
88
|
+
"@jarenjs/json": "^0.56.0"
|
|
89
|
+
},
|
|
90
|
+
"peerDependencies": {
|
|
91
|
+
"@jarenjs/db": "^0.56.0",
|
|
92
|
+
"@jarenjs/formats": "^0.56.0",
|
|
93
|
+
"@jarenjs/validate": "^0.56.0"
|
|
94
|
+
},
|
|
95
|
+
"peerDependenciesMeta": {
|
|
96
|
+
"@jarenjs/db": {
|
|
97
|
+
"optional": true
|
|
98
|
+
},
|
|
99
|
+
"@jarenjs/formats": {
|
|
100
|
+
"optional": true
|
|
101
|
+
},
|
|
102
|
+
"@jarenjs/validate": {
|
|
103
|
+
"optional": true
|
|
104
|
+
}
|
|
52
105
|
}
|
|
53
106
|
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `action()`, `transition()`, `effect()` and `bind()` — the
|
|
4
|
+
* transition half of an app document (APP-FORMAT §3–§5.1) and the event
|
|
5
|
+
* binding that reaches it (§4).
|
|
6
|
+
*
|
|
7
|
+
* An action is ONE query document over the state, so everything inside
|
|
8
|
+
* it — a patch value, an effect's props, a branch — is captured in the
|
|
9
|
+
* same scope, in one pass: `transition()` and `effect()` therefore
|
|
10
|
+
* assemble plain objects and leave the spelling to the capture that is
|
|
11
|
+
* already running, rather than starting their own. That is also what
|
|
12
|
+
* makes the format's own guarantee expressible: the increment in a
|
|
13
|
+
* patch and the one in the effect's props are the SAME expression,
|
|
14
|
+
* because both evaluate against the pre-transition state.
|
|
15
|
+
*
|
|
16
|
+
* A binding is built at RENDER time by the view stylesheet, so `bind()`
|
|
17
|
+
* is the same kind of value in a JSLT body — a payload may embed the
|
|
18
|
+
* matched node's own members, which is why the format needs no
|
|
19
|
+
* payload-creator function.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { LinqBuildError } from '../errors.js';
|
|
23
|
+
import { effectDescriptor, readEffects } from '../effect.js';
|
|
24
|
+
import { describeValue } from '../json-boundary.js';
|
|
25
|
+
import { isSchemaBuilder } from '../schema/brand.js';
|
|
26
|
+
import { captureAction } from './capture.js';
|
|
27
|
+
import { readPatch } from './patch.js';
|
|
28
|
+
|
|
29
|
+
/** The action brand: how `defineApp` tells a captured action apart. */
|
|
30
|
+
export const ACTION = Symbol.for('@jarenjs/linq/app-action');
|
|
31
|
+
|
|
32
|
+
/** The members `action()` takes beside its callback — both TYPES only. */
|
|
33
|
+
const ACTION_MEMBERS = Object.freeze(['payload', 'event']);
|
|
34
|
+
|
|
35
|
+
/** The members a transition object carries, in §3.2's order of application. */
|
|
36
|
+
const TRANSITION_MEMBERS = Object.freeze(['state', 'patch', 'effects']);
|
|
37
|
+
|
|
38
|
+
/** The members §4 gives the object binding form. */
|
|
39
|
+
const BIND_MEMBERS = Object.freeze(['payload', 'event', 'preventDefault', 'stopPropagation']);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The host-object-valued fields §3.1 excludes BY NAME. Every OTHER name
|
|
43
|
+
* is allowed — the built-in allow-list and a host extractor registered
|
|
44
|
+
* under any name it likes are both legal, and the pen cannot see the
|
|
45
|
+
* host's registry — but an extractor registered under one of these
|
|
46
|
+
* would shadow the exclusion the format exists to state, so the pen
|
|
47
|
+
* refuses the request and names §5.4's own worked example instead.
|
|
48
|
+
*/
|
|
49
|
+
const EXCLUDED_FIELDS = new Set([
|
|
50
|
+
'target', 'currentTarget', 'relatedTarget', 'srcElement', 'view',
|
|
51
|
+
'files', 'dataTransfer', 'touches', 'targetTouches', 'changedTouches',
|
|
52
|
+
'path', 'composedPath', 'clipboardData', 'submitter',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/** @param {any} value */
|
|
56
|
+
function isPlainObject(value) {
|
|
57
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A member set the pen knows, or `JL0101` naming the one it does not.
|
|
62
|
+
* @param {any} spec
|
|
63
|
+
* @param {readonly string[]} members
|
|
64
|
+
* @param {string} what
|
|
65
|
+
*/
|
|
66
|
+
function closedTo(spec, members, what) {
|
|
67
|
+
for (const key of Object.keys(spec)) {
|
|
68
|
+
if (!members.includes(key)) {
|
|
69
|
+
throw new LinqBuildError('JL0101',
|
|
70
|
+
`${what} does not take '${key}' — it takes ${members.join(', ')}`, `/${key}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A schema builder that TYPES something the document does not carry.
|
|
77
|
+
* @param {any} value
|
|
78
|
+
* @param {string} what - the member's own name, a NOUN: the message makes
|
|
79
|
+
* it the subject of a sentence, so a phrase here reads as nonsense
|
|
80
|
+
* @param {string} types - what the builder types, for the clause after it
|
|
81
|
+
* @param {string} at
|
|
82
|
+
*/
|
|
83
|
+
function typeOnly(value, what, types, at) {
|
|
84
|
+
if (!isSchemaBuilder(value)) {
|
|
85
|
+
throw new LinqBuildError('JL0101',
|
|
86
|
+
`${what} is a schema-pen builder — it types ${types}, and the app format carries no `
|
|
87
|
+
+ `schema for it, so nothing is emitted for it; got ${describeValue(value)}`, at);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One effect invocation (§5.1): `{ run, with? }`. The props are a value
|
|
93
|
+
* in the action's own scope — an expression, a literal, or a tree of
|
|
94
|
+
* both — because the action capture spells the whole transition.
|
|
95
|
+
* @param {string} run - the registered handler name (`options.effects[run]`)
|
|
96
|
+
* @param {any} [props] - the handler's `with`
|
|
97
|
+
* @returns {any} the effect declaration
|
|
98
|
+
* @example
|
|
99
|
+
* effect('http', { url: '/api/todos', done: 'todo/loaded' });
|
|
100
|
+
* effect('contract', { op: 'catalog.load', input: x.payload });
|
|
101
|
+
*/
|
|
102
|
+
export function effect(run, props = undefined) {
|
|
103
|
+
return effectDescriptor(run, props, (value) => value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A transition object (§3.2): the next state whole, an RFC 6902 patch
|
|
108
|
+
* over it, and the effects that run after it settles — written in the
|
|
109
|
+
* order the runtime applies them.
|
|
110
|
+
*
|
|
111
|
+
* Returning nothing from an action is the format's own no-op; a
|
|
112
|
+
* transition with no member is that same empty object, and is allowed.
|
|
113
|
+
*
|
|
114
|
+
* @param {{ state?: any, patch?: readonly any[], effects?: readonly any[] }} spec
|
|
115
|
+
* @returns {any} the transition, for the action capture to spell
|
|
116
|
+
* @throws {LinqBuildError} `JL0101` a member the pen does not know, or a
|
|
117
|
+
* patch/effects list that is not one
|
|
118
|
+
* @example
|
|
119
|
+
* transition({ patch: [add((st) => st.todos, x.payload)] });
|
|
120
|
+
* transition({ state: () => null, effects: [effect('save')] });
|
|
121
|
+
*/
|
|
122
|
+
export function transition(spec) {
|
|
123
|
+
if (!isPlainObject(spec)) {
|
|
124
|
+
throw new LinqBuildError('JL0101',
|
|
125
|
+
`transition() takes { state?, patch?, effects? }, got ${describeValue(spec)}`);
|
|
126
|
+
}
|
|
127
|
+
closedTo(spec, TRANSITION_MEMBERS, 'transition()');
|
|
128
|
+
const out = {};
|
|
129
|
+
if (spec.state !== undefined) out.state = spec.state;
|
|
130
|
+
if (spec.patch !== undefined) out.patch = readPatch(spec.patch);
|
|
131
|
+
if (spec.effects !== undefined) out.effects = readEffects(spec.effects, 'transition() effects');
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One action document (§3): a callback captured over the state, `$event`
|
|
137
|
+
* and `$payload`, whose result is a transition.
|
|
138
|
+
*
|
|
139
|
+
* `payload` and `event` type what the dispatch carries — the format
|
|
140
|
+
* holds no schema for either, so the pen emits nothing for them; what
|
|
141
|
+
* they buy is the typed `x.payload` and the `$event` members an action
|
|
142
|
+
* may read.
|
|
143
|
+
*
|
|
144
|
+
* @param {(state: any, externals: any) => any} fn - `(s, x) => transition(…)`
|
|
145
|
+
* @param {{ payload?: any, event?: readonly string[] }} [options]
|
|
146
|
+
* @returns {any} the action declaration
|
|
147
|
+
* @throws {LinqBuildError} `JL0101` a member the pen does not know, or a
|
|
148
|
+
* `payload` that is not a builder; `JL0102` an `event` field §3.1
|
|
149
|
+
* excludes; `JL0104` a name §3.1 does not bind
|
|
150
|
+
* @example
|
|
151
|
+
* action((s, x) => transition({ patch: [add((st) => st.todos, x.payload)] }),
|
|
152
|
+
* { payload: s.object({ text: s.string() }) });
|
|
153
|
+
*/
|
|
154
|
+
export function action(fn, options = undefined) {
|
|
155
|
+
if (typeof fn !== 'function') {
|
|
156
|
+
throw new LinqBuildError('JL0101',
|
|
157
|
+
`action() takes a callback (s, x) => transition(…), got ${describeValue(fn)}`);
|
|
158
|
+
}
|
|
159
|
+
if (options !== undefined) {
|
|
160
|
+
if (!isPlainObject(options)) {
|
|
161
|
+
throw new LinqBuildError('JL0101',
|
|
162
|
+
`action() options are { payload?, event? }, got ${describeValue(options)}`);
|
|
163
|
+
}
|
|
164
|
+
closedTo(options, ACTION_MEMBERS, 'action()');
|
|
165
|
+
if (options.payload !== undefined) {
|
|
166
|
+
typeOnly(options.payload, 'action() payload', "the dispatch's $payload", '/payload');
|
|
167
|
+
}
|
|
168
|
+
if (options.event !== undefined) readEventFields(options.event, 'action()');
|
|
169
|
+
}
|
|
170
|
+
const document = captureAction('action()', fn);
|
|
171
|
+
const out = { document };
|
|
172
|
+
Object.defineProperty(out, ACTION, { value: true, enumerable: false });
|
|
173
|
+
return Object.freeze(out);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The `event` member of a binding (§4): an array of the extra `$event`
|
|
178
|
+
* field names to resolve. A name §3.1 excludes by construction is
|
|
179
|
+
* `JL0102`; every other name is allowed, because a host extractor
|
|
180
|
+
* registered under it wins over the built-in list and the pen cannot
|
|
181
|
+
* see the host's registry.
|
|
182
|
+
* @param {any} fields
|
|
183
|
+
* @param {string} what
|
|
184
|
+
* @returns {string[]}
|
|
185
|
+
*/
|
|
186
|
+
function readEventFields(fields, what) {
|
|
187
|
+
if (!Array.isArray(fields)) {
|
|
188
|
+
throw new LinqBuildError('JL0101',
|
|
189
|
+
`${what} event is an array of field names (APP-FORMAT §4), got ${describeValue(fields)}`,
|
|
190
|
+
'/event');
|
|
191
|
+
}
|
|
192
|
+
return fields.map((name, i) => {
|
|
193
|
+
if (typeof name !== 'string' || name === '') {
|
|
194
|
+
throw new LinqBuildError('JL0101',
|
|
195
|
+
`${what} event[${i}] is a field name, got ${describeValue(name)}`, `/event/${i}`);
|
|
196
|
+
}
|
|
197
|
+
if (EXCLUDED_FIELDS.has(name)) {
|
|
198
|
+
throw new LinqBuildError('JL0102',
|
|
199
|
+
`an event binding cannot request '${name}' — APP-FORMAT §3.1 excludes target, files, `
|
|
200
|
+
+ 'touch lists and every other host-object-valued field by construction, because '
|
|
201
|
+
+ '$event MUST survive JSON.stringify, the same invariant as state. Register an '
|
|
202
|
+
+ 'extractor under a name of its own (§5.4\'s worked example maps event.target.files '
|
|
203
|
+
+ "to opaque string tokens under 'fileTokens') and request that.", `/event/${i}`);
|
|
204
|
+
}
|
|
205
|
+
return name;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* One event binding (§4): `{ action, with?, event?, preventDefault?,
|
|
211
|
+
* stopPropagation? }`, in the member order the format writes them.
|
|
212
|
+
*
|
|
213
|
+
* A payload is data built at render time, so it may embed anything in
|
|
214
|
+
* the stylesheet body's scope — the matched node's own members, `$path`,
|
|
215
|
+
* `$root` — which is what replaces a payload-creator function.
|
|
216
|
+
*
|
|
217
|
+
* @template {string} Names
|
|
218
|
+
* @param {Names} name - the action to dispatch
|
|
219
|
+
* @param {{ payload?: any, event?: readonly string[], preventDefault?: boolean, stopPropagation?: boolean }} [options]
|
|
220
|
+
* @returns {any} the binding, for the view's body capture to spell
|
|
221
|
+
* @throws {LinqBuildError} `JL0101` an empty name, a member the pen does
|
|
222
|
+
* not know, or a non-boolean control; `JL0102` an `event` field §3.1 excludes
|
|
223
|
+
* @example
|
|
224
|
+
* bind('selectRow', { payload: { id: v.id }, event: ['shiftKey', 'ctrlKey'] });
|
|
225
|
+
* bind('rowOpen', { stopPropagation: true });
|
|
226
|
+
*/
|
|
227
|
+
export function bind(name, options = undefined) {
|
|
228
|
+
if (typeof name !== 'string' || name === '') {
|
|
229
|
+
throw new LinqBuildError('JL0101',
|
|
230
|
+
`bind() takes an action name as a non-empty string, got ${describeValue(name)}`,
|
|
231
|
+
'/action');
|
|
232
|
+
}
|
|
233
|
+
const out = { action: name };
|
|
234
|
+
if (options !== undefined) {
|
|
235
|
+
if (!isPlainObject(options)) {
|
|
236
|
+
throw new LinqBuildError('JL0101',
|
|
237
|
+
'bind() options are { payload?, event?, preventDefault?, stopPropagation? }, got '
|
|
238
|
+
+ describeValue(options));
|
|
239
|
+
}
|
|
240
|
+
closedTo(options, BIND_MEMBERS, 'bind()');
|
|
241
|
+
if (options.payload !== undefined) out.with = options.payload;
|
|
242
|
+
if (options.event !== undefined) out.event = readEventFields(options.event, 'bind()');
|
|
243
|
+
for (const control of ['preventDefault', 'stopPropagation']) {
|
|
244
|
+
if (options[control] === undefined) continue;
|
|
245
|
+
if (typeof options[control] !== 'boolean') {
|
|
246
|
+
throw new LinqBuildError('JL0101',
|
|
247
|
+
`bind() ${control} is a boolean — it is allowed only on the object binding form `
|
|
248
|
+
+ `and defaults to false (APP-FORMAT §4), got ${describeValue(options[control])}`,
|
|
249
|
+
`/${control}`);
|
|
250
|
+
}
|
|
251
|
+
out[control] = options[control];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The one capture under an app document's query-valued members.
|
|
4
|
+
*
|
|
5
|
+
* An action is a query document evaluated with three names and no others
|
|
6
|
+
* (APP-FORMAT §3.1): `$` is the whole state, `$event` the serializable
|
|
7
|
+
* event slice, `$payload` the dispatch payload. So a callback receives
|
|
8
|
+
* the state at its first argument and those two externals at its second,
|
|
9
|
+
* and a name the loop does not bind is `JL0104` here — where the fix can
|
|
10
|
+
* be named — rather than an unbound external at dispatch time.
|
|
11
|
+
*
|
|
12
|
+
* A subscription's dynamic members evaluate in a NARROWER scope: `when`
|
|
13
|
+
* and `for` see the state alone, `withQuery` and `key` additionally see
|
|
14
|
+
* `$item` and only under `for` (§5.3, "compiled closed-world"). The
|
|
15
|
+
* pen mirrors that scope exactly, so a document it writes cannot be the
|
|
16
|
+
* `JA0008` a free variable would be at `createApp` time.
|
|
17
|
+
*
|
|
18
|
+
* Every capture here folds NOTHING: a transition, a binding and an
|
|
19
|
+
* effect's props are spelled as the constructors the format's own
|
|
20
|
+
* examples carry (`{ "op": "replace", … }`, `{ "ms": 1000 }`), never
|
|
21
|
+
* collapsed into one `$const`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { captureQuery } from '../capture-root.js';
|
|
25
|
+
|
|
26
|
+
/** §3.1's ambient vocabulary, for a `JL0104` message. */
|
|
27
|
+
export const ACTION_SCOPE = 'an action document (APP-FORMAT §3.1)';
|
|
28
|
+
|
|
29
|
+
/** The externals the dispatch loop binds on every action. */
|
|
30
|
+
const ACTION_EXTERNALS = Object.freeze(['event', 'payload']);
|
|
31
|
+
|
|
32
|
+
/** How an unbound action name could be reached, appended to `JL0104`. */
|
|
33
|
+
const ACTION_ADVICE = () => ' — the whole state is the first argument; §3.1 binds $event and '
|
|
34
|
+
+ '$payload and nothing else, and a host value reaches an action through a binding\'s '
|
|
35
|
+
+ 'payload or an event-field extractor';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Capture one action-scope callback: `(s, x) => …` over `$`, `$event`
|
|
39
|
+
* and `$payload`.
|
|
40
|
+
* @param {string} what - the method, for the message
|
|
41
|
+
* @param {(state: any, externals: any) => any} fn
|
|
42
|
+
* @returns {any} the query document (plain JSON)
|
|
43
|
+
*/
|
|
44
|
+
export function captureAction(what, fn) {
|
|
45
|
+
return captureQuery(what, ACTION_EXTERNALS, fn, { advice: ACTION_ADVICE, fold: false });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Capture one subscription member over §5.3's closed world.
|
|
50
|
+
* @param {string} what - the member, for the message
|
|
51
|
+
* @param {readonly string[]} externals - `['item']` under `for`, else none
|
|
52
|
+
* @param {any} value - a callback, or a query document written by hand
|
|
53
|
+
* @returns {any} the query document (plain JSON)
|
|
54
|
+
*/
|
|
55
|
+
export function captureSub(what, externals, value) {
|
|
56
|
+
if (typeof value !== 'function') return JSON.parse(JSON.stringify(value));
|
|
57
|
+
const advice = () => (externals.length === 0
|
|
58
|
+
? ` — a subscription's ${what} is compiled closed-world against the state alone; $item `
|
|
59
|
+
+ 'exists only under a "for" declaration (APP-FORMAT §5.3)'
|
|
60
|
+
: ` — a subscription's ${what} under "for" sees the state and $item, nothing else `
|
|
61
|
+
+ '(APP-FORMAT §5.3)');
|
|
62
|
+
return captureQuery(`sub() ${what}`, externals, value, { advice, fold: false });
|
|
63
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `defineApp()` — one `jaren-app` 0.1 document (APP-FORMAT §2),
|
|
4
|
+
* deep-frozen, that `createApp` takes unchanged, and beside it the JSON
|
|
5
|
+
* Schema of its state.
|
|
6
|
+
*
|
|
7
|
+
* The two are answered as two members of ONE result and never merged:
|
|
8
|
+
* the format has no slot for a state schema, and the hook that wants one
|
|
9
|
+
* is `options.validateState` (§6), which is a `createApp` option rather
|
|
10
|
+
* than a document member. `{ document, stateSchema }` is therefore what
|
|
11
|
+
* the pen returns — the document is exactly the format's, and the schema
|
|
12
|
+
* goes where the invariant lives.
|
|
13
|
+
*
|
|
14
|
+
* The initial state is DERIVED from the state builder's `default()`s
|
|
15
|
+
* when the author does not write one: a member contributes its default,
|
|
16
|
+
* an object recurses, and a REQUIRED member that resolves to nothing is
|
|
17
|
+
* `JL0102` naming its pointer — because a state whose required member is
|
|
18
|
+
* absent fails its own `validateState` on the boot transaction, which is
|
|
19
|
+
* a fatal `JA0007` and a bad way to learn about a missing default.
|
|
20
|
+
*
|
|
21
|
+
* `defineApp` also holds one rule §4 leaves to run time: every literal
|
|
22
|
+
* action name the view binds must be declared. The runtime reports an
|
|
23
|
+
* unknown name as `JA2001` per dispatch, silently dropping the user's
|
|
24
|
+
* click; the pen can see the whole document at once and refuses it.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { deepFreeze, setObjectMember } from '@jarenjs/core/object';
|
|
28
|
+
|
|
29
|
+
import { LinqBuildError } from '../errors.js';
|
|
30
|
+
import { describeValue, requireJson, requireNameMap } from '../json-boundary.js';
|
|
31
|
+
import { isSchemaBuilder, schemaOf } from '../schema/brand.js';
|
|
32
|
+
import { ACTION } from './action.js';
|
|
33
|
+
import { SUB } from './sub.js';
|
|
34
|
+
|
|
35
|
+
const APP_VERSION = '0.1';
|
|
36
|
+
|
|
37
|
+
/** The members `defineApp()` takes. */
|
|
38
|
+
const APP_MEMBERS = Object.freeze(['state', 'initial', 'schema', 'view', 'actions', 'subs']);
|
|
39
|
+
|
|
40
|
+
/** @param {any} value */
|
|
41
|
+
function isPlainObject(value) {
|
|
42
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A JSON value, copied: the document is a value of its own. @param {any} v */
|
|
46
|
+
const copy = (v) => JSON.parse(JSON.stringify(v));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The initial value a schema's `default()`s describe.
|
|
50
|
+
*
|
|
51
|
+
* A `default` answers itself. An object schema recurses: every member
|
|
52
|
+
* that resolves contributes, and a member `required` names but which
|
|
53
|
+
* resolves to nothing is refused. An object resolves when it is
|
|
54
|
+
* required, or when at least one of its own members did — so an
|
|
55
|
+
* optional block of defaults appears and an optional empty one does not.
|
|
56
|
+
*
|
|
57
|
+
* @param {any} schema
|
|
58
|
+
* @param {string} at - the pointer into the state, for the message
|
|
59
|
+
* @param {boolean} needed - whether the parent requires this member
|
|
60
|
+
* @returns {{ has: boolean, value?: any }}
|
|
61
|
+
*/
|
|
62
|
+
function initialOf(schema, at, needed) {
|
|
63
|
+
if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
64
|
+
return { has: false };
|
|
65
|
+
}
|
|
66
|
+
if (schema.default !== undefined) return { has: true, value: copy(schema.default) };
|
|
67
|
+
if (schema.const !== undefined) return { has: true, value: copy(schema.const) };
|
|
68
|
+
if (!isPlainObject(schema.properties)) return { has: false };
|
|
69
|
+
|
|
70
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
71
|
+
const out = {};
|
|
72
|
+
let any = false;
|
|
73
|
+
for (const [name, member] of Object.entries(schema.properties)) {
|
|
74
|
+
const isRequired = required.includes(name);
|
|
75
|
+
const resolved = initialOf(member, `${at}/${name}`, isRequired);
|
|
76
|
+
if (resolved.has) { setObjectMember(out, name, resolved.value); any = true; continue; }
|
|
77
|
+
if (!isRequired) continue;
|
|
78
|
+
throw new LinqBuildError('JL0102',
|
|
79
|
+
`the initial state cannot be derived: '${at}/${name}' is required and declares no `
|
|
80
|
+
+ 'default — give the member a default(), or pass the whole initial state as '
|
|
81
|
+
+ "defineApp()'s 'initial'", `${at}/${name}`);
|
|
82
|
+
}
|
|
83
|
+
return needed || any ? { has: true, value: out } : { has: false };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A literal action name, or `null`: a value starting with `$` is a query
|
|
88
|
+
* expression naming the action at render time, which no pen can resolve.
|
|
89
|
+
* @param {any} value
|
|
90
|
+
* @returns {string | null}
|
|
91
|
+
*/
|
|
92
|
+
function literalName(value) {
|
|
93
|
+
return typeof value === 'string' && value !== '' && value[0] !== '$' ? value : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Every literal action name the view binds. §4 gives a binding two
|
|
98
|
+
* forms and this reads both: a string under an `on` map (a vnode's
|
|
99
|
+
* event props, VIEW-FORMAT's own member), and an object carrying an
|
|
100
|
+
* `action` member anywhere — that member IS §4's vocabulary, so an
|
|
101
|
+
* object holding one is a binding wherever a widget's `emit` will find
|
|
102
|
+
* it.
|
|
103
|
+
* @param {any} node
|
|
104
|
+
* @param {Set<string>} out
|
|
105
|
+
*/
|
|
106
|
+
function collectBoundActions(node, out) {
|
|
107
|
+
if (Array.isArray(node)) {
|
|
108
|
+
for (const child of node) collectBoundActions(child, out);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (node === null || typeof node !== 'object') return;
|
|
112
|
+
for (const [key, value] of Object.entries(node)) {
|
|
113
|
+
if (key === 'action') {
|
|
114
|
+
const name = literalName(value);
|
|
115
|
+
if (name !== null) out.add(name);
|
|
116
|
+
}
|
|
117
|
+
if (key === 'on' && isPlainObject(value)) {
|
|
118
|
+
for (const binding of Object.values(value)) {
|
|
119
|
+
const name = literalName(binding);
|
|
120
|
+
if (name !== null) out.add(name);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
collectBoundActions(value, out);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The `view` member: a JSLT stylesheet, envelope or bare rule array
|
|
129
|
+
* (§2), from the JSLT pen or by hand.
|
|
130
|
+
* @param {any} view
|
|
131
|
+
* @returns {any}
|
|
132
|
+
*/
|
|
133
|
+
function readView(view) {
|
|
134
|
+
if (view === undefined) {
|
|
135
|
+
throw new LinqBuildError('JL0101',
|
|
136
|
+
'defineApp() needs a view — a JSLT stylesheet, from stylesheet([rule(…)]) or as a '
|
|
137
|
+
+ 'bare rule array; the format requires the member and the runtime refuses an app '
|
|
138
|
+
+ 'without one (JA0002)', '/view');
|
|
139
|
+
}
|
|
140
|
+
if (!Array.isArray(view) && !isPlainObject(view)) {
|
|
141
|
+
throw new LinqBuildError('JL0101',
|
|
142
|
+
`defineApp() view is a JSLT stylesheet document or a bare rule array, got ${describeValue(view)}`,
|
|
143
|
+
'/view');
|
|
144
|
+
}
|
|
145
|
+
return copy(requireJson(view, 'defineApp() view'));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Write a `jaren-app` 0.1 document (APP-FORMAT.md §2) and the JSON
|
|
150
|
+
* Schema of its state.
|
|
151
|
+
*
|
|
152
|
+
* @param {any} spec - `{ state, initial?, schema?, view, actions?, subs? }`
|
|
153
|
+
* @returns {{ document: any, stateSchema: any }} the deep-frozen document
|
|
154
|
+
* and the state's schema (`null` when the state is a plain value with
|
|
155
|
+
* no `schema` beside it)
|
|
156
|
+
* @throws {LinqBuildError} `JL0101` a value the pen cannot spell;
|
|
157
|
+
* `JL0102` an initial state no default describes, or a bound action
|
|
158
|
+
* name `actions` does not declare
|
|
159
|
+
* @example
|
|
160
|
+
* const { document, stateSchema } = defineApp({
|
|
161
|
+
* state: s.object({ count: s.integer().default(0) }),
|
|
162
|
+
* view: [rule('$', (v) => ['h1', {}, 'Count: ', v.count])],
|
|
163
|
+
* actions: { inc: action((st) => transition({ patch: [replace((c) => c.count, st.count.add(1))] })) },
|
|
164
|
+
* });
|
|
165
|
+
* createApp(document, { node, validateState: new JarenValidator().compile(stateSchema) });
|
|
166
|
+
*/
|
|
167
|
+
export function defineApp(spec) {
|
|
168
|
+
if (!isPlainObject(spec)) {
|
|
169
|
+
throw new LinqBuildError('JL0101',
|
|
170
|
+
`defineApp() takes { state, initial?, schema?, view, actions?, subs? }, got ${describeValue(spec)}`);
|
|
171
|
+
}
|
|
172
|
+
for (const key of Object.keys(spec)) {
|
|
173
|
+
if (!APP_MEMBERS.includes(key)) {
|
|
174
|
+
throw new LinqBuildError('JL0101',
|
|
175
|
+
`defineApp() does not take '${key}' — it takes ${APP_MEMBERS.join(', ')}`, `/${key}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const stateIsBuilder = isSchemaBuilder(spec.state);
|
|
180
|
+
if (stateIsBuilder && spec.schema !== undefined) {
|
|
181
|
+
throw new LinqBuildError('JL0101',
|
|
182
|
+
"defineApp() takes 'schema' beside a state given as a plain JSON value — a state "
|
|
183
|
+
+ 'given as a builder IS its schema', '/schema');
|
|
184
|
+
}
|
|
185
|
+
if (spec.schema !== undefined && !isSchemaBuilder(spec.schema)) {
|
|
186
|
+
throw new LinqBuildError('JL0101',
|
|
187
|
+
`defineApp() schema is a schema-pen builder, got ${describeValue(spec.schema)}`, '/schema');
|
|
188
|
+
}
|
|
189
|
+
const stateSchema = stateIsBuilder ? schemaOf(spec.state)
|
|
190
|
+
: spec.schema !== undefined ? schemaOf(spec.schema) : null;
|
|
191
|
+
|
|
192
|
+
let state;
|
|
193
|
+
if (spec.initial !== undefined) state = copy(requireJson(spec.initial, 'defineApp() initial'));
|
|
194
|
+
else if (stateIsBuilder) state = initialOf(stateSchema, '', true).value;
|
|
195
|
+
else if (spec.state !== undefined) state = copy(requireJson(spec.state, 'defineApp() state'));
|
|
196
|
+
|
|
197
|
+
const view = readView(spec.view);
|
|
198
|
+
|
|
199
|
+
/** @type {Record<string, any> | undefined} */
|
|
200
|
+
let actions;
|
|
201
|
+
if (spec.actions !== undefined) {
|
|
202
|
+
if (!isPlainObject(spec.actions)) {
|
|
203
|
+
throw new LinqBuildError('JL0101',
|
|
204
|
+
`defineApp() actions is an object of named action() declarations, got ${describeValue(spec.actions)}`,
|
|
205
|
+
'/actions');
|
|
206
|
+
}
|
|
207
|
+
requireNameMap(spec.actions, 'defineApp() actions', '/actions');
|
|
208
|
+
actions = {};
|
|
209
|
+
for (const [name, declared] of Object.entries(spec.actions)) {
|
|
210
|
+
if (!isPlainObject(declared) || declared[ACTION] !== true) {
|
|
211
|
+
throw new LinqBuildError('JL0101',
|
|
212
|
+
`defineApp() action '${name}' is action((s, x) => transition(…)), got ${describeValue(declared)}`,
|
|
213
|
+
`/actions/${name}`);
|
|
214
|
+
}
|
|
215
|
+
setObjectMember(actions, name, declared.document);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** @type {any[] | undefined} */
|
|
220
|
+
let subs;
|
|
221
|
+
if (spec.subs !== undefined) {
|
|
222
|
+
if (!Array.isArray(spec.subs)) {
|
|
223
|
+
throw new LinqBuildError('JL0101',
|
|
224
|
+
`defineApp() subs is an array of sub() declarations, got ${describeValue(spec.subs)}`,
|
|
225
|
+
'/subs');
|
|
226
|
+
}
|
|
227
|
+
subs = spec.subs.map((declared, i) => {
|
|
228
|
+
if (!isPlainObject(declared) || declared[SUB] !== true) {
|
|
229
|
+
throw new LinqBuildError('JL0101',
|
|
230
|
+
`defineApp() subs[${i}] is sub(run, options?), got ${describeValue(declared)}`,
|
|
231
|
+
`/subs/${i}`);
|
|
232
|
+
}
|
|
233
|
+
return { ...declared };
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** @type {Set<string>} */
|
|
238
|
+
const bound = new Set();
|
|
239
|
+
collectBoundActions(view, bound);
|
|
240
|
+
const declared = Object.keys(actions ?? {});
|
|
241
|
+
for (const name of bound) {
|
|
242
|
+
if (declared.includes(name)) continue;
|
|
243
|
+
throw new LinqBuildError('JL0102',
|
|
244
|
+
`the view binds the action '${name}', which "actions" does not declare — the runtime `
|
|
245
|
+
+ 'drops such a dispatch and reports JA2001 (APP-FORMAT §4), so the user\'s click does '
|
|
246
|
+
+ `nothing; the declared actions are ${declared.length === 0 ? 'none'
|
|
247
|
+
: declared.map((n) => `'${n}'`).join(', ')}`, '/view');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const document = { $app: APP_VERSION };
|
|
251
|
+
if (state !== undefined) document.state = state;
|
|
252
|
+
document.view = view;
|
|
253
|
+
if (actions !== undefined) document.actions = actions;
|
|
254
|
+
if (subs !== undefined) document.subs = subs;
|
|
255
|
+
|
|
256
|
+
return Object.freeze({
|
|
257
|
+
document: deepFreeze(document),
|
|
258
|
+
stateSchema: stateSchema === null ? null : deepFreeze(copy(stateSchema)),
|
|
259
|
+
});
|
|
260
|
+
}
|
package/src/app/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `@jarenjs/linq/app` — a `jaren-app` 0.1 application by code.
|
|
4
|
+
* `defineApp()` writes the document `createApp` runs: a state whose
|
|
5
|
+
* initial value comes from its own schema's defaults, the JSLT pen's
|
|
6
|
+
* stylesheet as the view, actions captured over APP-FORMAT §3.1's three
|
|
7
|
+
* names, patch paths derived from the state shape, and subscriptions
|
|
8
|
+
* compiled in §5.3's closed world. It answers the document and the
|
|
9
|
+
* state's JSON Schema as two members of one result — the format has no
|
|
10
|
+
* slot for the second, and `validateState` is where it belongs.
|
|
11
|
+
*
|
|
12
|
+
* Everything the document holds is data, so everything this pen writes
|
|
13
|
+
* is JSON: nothing here imports `@jarenjs/app`, and the loop's compiler
|
|
14
|
+
* stays the only judge of what an app means.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export { defineApp } from './define.js';
|
|
18
|
+
export { action, transition, effect, bind } from './action.js';
|
|
19
|
+
export { add, append, replace, remove, move, copy, test } from './patch.js';
|
|
20
|
+
export { sub } from './sub.js';
|