@jarenjs/studio 0.73.0 → 0.83.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -2
- package/dist/types/assemble.d.ts +5 -8
- package/dist/types/author.d.ts +27 -0
- package/dist/types/component/index.d.ts +46 -29
- package/dist/types/component/view.d.ts +45 -28
- package/dist/types/errors.d.ts +1 -0
- package/dist/types/export.d.ts +5 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/resolve.d.ts +27 -0
- package/dist/types/skeletons.d.ts +6 -0
- package/dist/types/validate.d.ts +1 -1
- package/docs/PROJECT-FORMAT.md +99 -24
- package/package.json +18 -9
- package/schemas/jaren-project.draft-07.schema.json +80 -12
- package/schemas/jaren-project.schema.json +77 -13
- package/src/assemble.js +34 -14
- package/src/author.js +55 -0
- package/src/component/view.js +14 -8
- package/src/component/viewmodel.js +13 -8
- package/src/errors.js +1 -0
- package/src/export.js +66 -0
- package/src/index.js +2 -0
- package/src/project.js +1 -1
- package/src/resolve.js +111 -0
- package/src/skeletons.js +33 -0
- package/src/validate.js +26 -7
- package/styles/studio.css +14 -0
package/src/resolve.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Project-local references. No fetching, evaluation or implicit merging. */
|
|
3
|
+
import { setObjectMember, semanticKey } from '@jarenjs/core/object';
|
|
4
|
+
import { StudioError } from './errors.js';
|
|
5
|
+
|
|
6
|
+
const MEMBERS = {
|
|
7
|
+
app: ['view', 'actions', 'state', 'subs'],
|
|
8
|
+
fsm: ['states', 'transitions', 'initial'],
|
|
9
|
+
dag: ['nodes', 'edges', 'output'],
|
|
10
|
+
model: ['collections', 'entities'],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Resolve one file and its transitive sources. Imported members must be
|
|
14
|
+
* absent locally; a typo, cycle or collision is a refusal, never a fallback.
|
|
15
|
+
* @param {any} project @param {string} name
|
|
16
|
+
* @returns {{ doc: any, sourceFiles: string[] }} */
|
|
17
|
+
export function resolveProjectFile(project, name) {
|
|
18
|
+
const visiting = new Set();
|
|
19
|
+
const sources = new Set();
|
|
20
|
+
const read = (name) => {
|
|
21
|
+
const file = project.files.find((f) => f.name === name);
|
|
22
|
+
if (!file) throw new StudioError('JS0003', `missing project file '${name}'`);
|
|
23
|
+
if (visiting.has(name)) throw new StudioError('JS0003', `cyclic file import at '${name}'`);
|
|
24
|
+
visiting.add(name);
|
|
25
|
+
sources.add(name);
|
|
26
|
+
let doc;
|
|
27
|
+
try { doc = JSON.parse(file.text); }
|
|
28
|
+
catch (cause) { throw new StudioError('JS0003', `${name}: not valid JSON`, '', { cause }); }
|
|
29
|
+
for (const [member, source] of Object.entries(file.imports ?? {})) {
|
|
30
|
+
if (!MEMBERS[file.kind]?.includes(member))
|
|
31
|
+
throw new StudioError('JS0003', `${name}: '${member}' is not an importable ${file.kind} member`);
|
|
32
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc))
|
|
33
|
+
throw new StudioError('JS0003', `${name}: a fragment destination must be an object`);
|
|
34
|
+
if (Object.hasOwn(doc, member))
|
|
35
|
+
throw new StudioError('JS0003', `${name}: imported member '${member}' is also defined locally`);
|
|
36
|
+
setObjectMember(doc, member, read(source));
|
|
37
|
+
}
|
|
38
|
+
visiting.delete(name);
|
|
39
|
+
return doc;
|
|
40
|
+
};
|
|
41
|
+
return { doc: read(name), sourceFiles: [...sources] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolve a query/transform/validation input or a worker store route.
|
|
45
|
+
* Explicit references never fall back when broken. Multiple models require
|
|
46
|
+
* a choice; adding an unrelated model cannot silently reroute a query.
|
|
47
|
+
* @param {any} project @param {any} file
|
|
48
|
+
* @returns {{ input: any, model: any, collection: string | null }} */
|
|
49
|
+
export function projectFileContext(project, file) {
|
|
50
|
+
const named = (name, kinds) => {
|
|
51
|
+
const found = project.files.find((f) => f.name === name);
|
|
52
|
+
if (!found || !kinds.includes(found.kind))
|
|
53
|
+
throw new StudioError('JS0003', `${file.name}: '${name}' must name a ${kinds.join('/')} file`);
|
|
54
|
+
return found;
|
|
55
|
+
};
|
|
56
|
+
const input = file.input !== undefined ? named(file.input, ['data', 'state'])
|
|
57
|
+
: project.files.find((f) => f.kind === 'data') ?? project.files.find((f) => f.kind === 'state') ?? null;
|
|
58
|
+
const model = file.model !== undefined ? named(file.model, ['model']) : null;
|
|
59
|
+
if (file.collection !== undefined && model === null && file.kind !== 'model')
|
|
60
|
+
throw new StudioError('JS0003', `${file.name}: collection requires a model reference`);
|
|
61
|
+
return { input, model, collection: file.collection ?? null };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Rename references together with their target, preserving every other
|
|
65
|
+
* file member. A delete deliberately leaves references visibly broken.
|
|
66
|
+
* @param {any[]} files @param {string} before @param {string} after */
|
|
67
|
+
export function renameProjectFile(files, before, after) {
|
|
68
|
+
if (!after || files.some((f) => f.name === after && f.name !== before))
|
|
69
|
+
throw new StudioError('JS0002', `a file is already named '${after}'`);
|
|
70
|
+
return files.map((file) => ({ ...file,
|
|
71
|
+
name: file.name === before ? after : file.name,
|
|
72
|
+
...(file.input === before ? { input: after } : {}),
|
|
73
|
+
...(file.model === before ? { model: after } : {}),
|
|
74
|
+
...(file.imports ? { imports: Object.fromEntries(Object.entries(file.imports)
|
|
75
|
+
.map(([key, name]) => [key, name === before ? after : name])) } : {}),
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Write an assembled artifact back into its source files. Imported
|
|
80
|
+
* members stay in their own files; conflicting writes to a shared source
|
|
81
|
+
* are refused atomically. Unchanged files keep their identity and text.
|
|
82
|
+
* @param {any} project @param {string} name @param {any} doc */
|
|
83
|
+
export function writeProjectArtifact(project, name, doc) {
|
|
84
|
+
resolveProjectFile(project, name); // resolve/cycle/collision check before any write
|
|
85
|
+
const writes = new Map();
|
|
86
|
+
const write = (name, value) => {
|
|
87
|
+
const key = semanticKey(value);
|
|
88
|
+
if (writes.has(name)) {
|
|
89
|
+
if (writes.get(name).key !== key) throw new StudioError('JS0003', `conflicting values for shared source '${name}'`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const file = project.files.find((f) => f.name === name);
|
|
93
|
+
let local = value;
|
|
94
|
+
if (file.imports) {
|
|
95
|
+
local = { ...value };
|
|
96
|
+
for (const [member, source] of Object.entries(file.imports)) {
|
|
97
|
+
if (!Object.hasOwn(value, member)) throw new StudioError('JS0003', `the edit removed imported member '${member}'`);
|
|
98
|
+
write(source, value[member]);
|
|
99
|
+
delete local[member];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
writes.set(name, { key, local });
|
|
103
|
+
};
|
|
104
|
+
write(name, doc);
|
|
105
|
+
return project.files.map((file) => {
|
|
106
|
+
if (!writes.has(file.name)) return file;
|
|
107
|
+
const value = writes.get(file.name).local;
|
|
108
|
+
if (semanticKey(JSON.parse(file.text)) === semanticKey(value)) return file;
|
|
109
|
+
return { ...file, text: JSON.stringify(value, null, 2) };
|
|
110
|
+
});
|
|
111
|
+
}
|
package/src/skeletons.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** Valid starting documents, shared by the menu, host and assistant. */
|
|
3
|
+
const SKELETONS = {
|
|
4
|
+
app: JSON.stringify({ state: {}, view: [{ match: '$', body: ['p', {}, 'New app'] }], actions: {} }, null, 2),
|
|
5
|
+
jslt: JSON.stringify({ $jslt: '0.1', rules: [{ match: '$', body: '$' }] }, null, 2),
|
|
6
|
+
query: JSON.stringify({ value: '$' }, null, 2),
|
|
7
|
+
fsm: JSON.stringify({ $fsm: '0.1', states: ['idle', 'done'], initial: 'idle', transitions: [{ from: 'idle', event: 'finish', to: 'done' }] }, null, 2),
|
|
8
|
+
dag: JSON.stringify({ $dag: '0.1', nodes: { input: { kind: 'input' }, output: { kind: 'output' } }, edges: [{ from: 'input', to: 'output' }], output: 'output' }, null, 2),
|
|
9
|
+
model: JSON.stringify({ $model: '0.1', collections: { notes: { schema: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, points: { type: 'integer' } }, required: ['id'] }, key: '/id', indexes: [{ name: 'by_points', path: '$.points' }] } } }, null, 2),
|
|
10
|
+
state: '{}',
|
|
11
|
+
data: '{}',
|
|
12
|
+
schema: JSON.stringify({ type: 'object' }, null, 2),
|
|
13
|
+
contract: JSON.stringify({
|
|
14
|
+
$contract: '0.1',
|
|
15
|
+
operations: {
|
|
16
|
+
'echo.say': {
|
|
17
|
+
kind: 'command',
|
|
18
|
+
input: { type: 'object', required: ['text'], properties: { text: { type: 'string' } } },
|
|
19
|
+
output: true,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}, null, 2),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Creation kinds derive from the valid starter table and are shared by
|
|
26
|
+
* the component menu, website host and assistant. */
|
|
27
|
+
export const ADDABLE_KINDS = Object.freeze(Object.keys(SKELETONS));
|
|
28
|
+
|
|
29
|
+
/** The starter text for a freshly added file of `kind`, or null if the
|
|
30
|
+
* kind is not addable. */
|
|
31
|
+
export function fileSkeleton(kind) {
|
|
32
|
+
return Object.hasOwn(SKELETONS, kind) ? SKELETONS[kind] : null;
|
|
33
|
+
}
|
package/src/validate.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* so host-registered operators ($npv, $sqrt) validate and a
|
|
13
13
|
* real error comes back as its own coded `JQ`/`JT` code with
|
|
14
14
|
* a docPath — the closed grammar would reject the operators;
|
|
15
|
-
* - `fsm` / `dag` / `model` → their
|
|
15
|
+
* - `fsm` / `dag` / `model` → their grammar plus compiler/planner checks;
|
|
16
16
|
* - `contract` → COMPILED by `compileContract`, so a refusal comes back
|
|
17
17
|
* as its stable `JC00xx` code with the docPath of the
|
|
18
18
|
* member at fault — richer than the grammar alone;
|
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
compileJsltStylesheet, createJsltRegistry, mathPack, financePack, statsPack,
|
|
32
32
|
} from '@jarenjs/json/jslt';
|
|
33
33
|
import { compileContract } from '@jarenjs/contract';
|
|
34
|
+
import { compileFsm, compileDag } from '@jarenjs/flow';
|
|
35
|
+
import { normalizeModel, normalizeEntities, planCollection, planEntity, explainMapping, sqliteDialect } from '@jarenjs/db';
|
|
34
36
|
|
|
35
37
|
import appSchema from '@jarenjs/app/schemas/jaren-app.schema.json' with { type: 'json' };
|
|
36
38
|
import querySchema from '@jarenjs/json/schemas/jaren-query.schema.json' with { type: 'json' };
|
|
@@ -176,12 +178,29 @@ function validateFileUncached(file, options = {}) {
|
|
|
176
178
|
return compileResult(kind, () => compileJsltStylesheet(doc, { compileTypeTest, ...registryOptions }));
|
|
177
179
|
case 'query':
|
|
178
180
|
return compileResult(kind, () => compileJsonQuery(doc, { compileTypeTest, ...registryOptions }));
|
|
179
|
-
case 'fsm':
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
case '
|
|
184
|
-
|
|
181
|
+
case 'fsm': {
|
|
182
|
+
const structural = schemaResult(kind, validateFsm(doc));
|
|
183
|
+
return structural.valid ? compileResult(kind, () => compileFsm(doc)) : structural;
|
|
184
|
+
}
|
|
185
|
+
case 'dag': {
|
|
186
|
+
const structural = schemaResult(kind, validateDag(doc));
|
|
187
|
+
if (!structural.valid) return structural;
|
|
188
|
+
// Validate named task nodes without acquiring or running host tasks.
|
|
189
|
+
const tasks = Object.fromEntries(Object.values(doc.nodes ?? {})
|
|
190
|
+
.filter((node) => node.kind === 'task').map((node) => [node.run, () => null]));
|
|
191
|
+
return compileResult(kind, () => compileDag(doc, { tasks }));
|
|
192
|
+
}
|
|
193
|
+
case 'model': {
|
|
194
|
+
const structural = schemaResult(kind, validateModel(doc));
|
|
195
|
+
if (!structural.valid) return structural;
|
|
196
|
+
return compileResult(kind, () => {
|
|
197
|
+
for (const [name, collection] of normalizeModel(doc)) planCollection(name, collection, sqliteDialect);
|
|
198
|
+
if (normalizeEntities(doc).size > 0) {
|
|
199
|
+
const mapping = explainMapping(doc);
|
|
200
|
+
for (const name of Object.keys(mapping.entities)) planEntity(name, mapping.entities[name], mapping, sqliteDialect);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
185
204
|
case 'contract':
|
|
186
205
|
return compileResult(kind, () => compileContract(doc));
|
|
187
206
|
case 'schema':
|
package/styles/studio.css
CHANGED
|
@@ -231,3 +231,17 @@
|
|
|
231
231
|
plain vertical list again — the whole screen is the file list */
|
|
232
232
|
.js-file-name { white-space: normal; overflow-wrap: anywhere; }
|
|
233
233
|
}
|
|
234
|
+
|
|
235
|
+
/* Per-file routing and the hosted flow/store stages. */
|
|
236
|
+
.js-routing { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px; font-size: .8rem; }
|
|
237
|
+
.js-routing label { display: flex; align-items: center; gap: 4px; min-width: 0; }
|
|
238
|
+
.js-routing input, .js-routing select { max-width: 180px; min-width: 0; }
|
|
239
|
+
.project-flow, .project-data { min-width: 0; padding: 12px; }
|
|
240
|
+
.project-flow .flow-grid { grid-template-columns: minmax(0, 1fr); }
|
|
241
|
+
.project-data pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
242
|
+
.project-data textarea { display: block; width: 100%; box-sizing: border-box; }
|
|
243
|
+
.project-data label { display: block; margin-top: 12px; }
|
|
244
|
+
.project-data button { margin: 8px 0; }
|
|
245
|
+
.project-data button, .project-data input { min-height: 44px; }
|
|
246
|
+
.project-data summary { cursor: pointer; padding: 12px 0; min-height: 44px; box-sizing: border-box; }
|
|
247
|
+
.project-data input { box-sizing: border-box; }
|