@jarenjs/studio 0.75.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/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
+ }
@@ -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 published grammar (structural);
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
- return schemaResult(kind, validateFsm(doc));
181
- case 'dag':
182
- return schemaResult(kind, validateDag(doc));
183
- case 'model':
184
- return schemaResult(kind, validateModel(doc));
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; }