@jarenjs/studio 0.83.2 → 0.84.3
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 +21 -6
- package/contracts/data.contract.json +243 -0
- package/dist/types/component/data/actions.d.ts +234 -0
- package/dist/types/component/data/index.d.ts +9 -0
- package/dist/types/component/data/mount.d.ts +87 -0
- package/dist/types/component/data/project-widget.d.ts +27 -0
- package/dist/types/component/data/viewmodel.d.ts +85 -0
- package/dist/types/component/data/views.d.ts +148 -0
- package/dist/types/component/document.d.ts +82 -0
- package/dist/types/component/flow/actions.d.ts +708 -0
- package/dist/types/component/flow/index.d.ts +7 -0
- package/dist/types/component/flow/mount.d.ts +66 -0
- package/dist/types/component/flow/project-widget.d.ts +8 -0
- package/dist/types/component/flow/runtime.d.ts +57 -0
- package/dist/types/component/flow/views.d.ts +242 -0
- package/dist/types/component/host.d.ts +2 -2
- package/dist/types/component/index.d.ts +21 -15
- package/dist/types/component/mount.d.ts +51 -0
- package/dist/types/component/project-actions.d.ts +302 -0
- package/dist/types/component/project-controller.d.ts +81 -0
- package/dist/types/component/project-state.d.ts +1 -0
- package/dist/types/component/project.d.ts +327 -0
- package/dist/types/component/shared/host-widget.d.ts +24 -0
- package/dist/types/component/shared/memo.d.ts +12 -0
- package/dist/types/component/shared/nodes.d.ts +92 -0
- package/dist/types/component/shared/schema-options.d.ts +28 -0
- package/dist/types/component/shared/studio-kit.d.ts +78 -0
- package/dist/types/component/shared/ui.d.ts +136 -0
- package/dist/types/component/view.d.ts +6 -6
- package/dist/types/data/boot-stages.d.ts +97 -0
- package/dist/types/data/browser-worker.d.ts +22 -0
- package/dist/types/data/contract.d.ts +9 -0
- package/dist/types/data/editor.d.ts +81 -0
- package/dist/types/data/handlers.d.ts +130 -0
- package/dist/types/data/host.d.ts +8 -0
- package/dist/types/data/project-worker.d.ts +8 -0
- package/dist/types/data/runtime.d.ts +46 -0
- package/dist/types/data/state.d.ts +33 -0
- package/dist/types/data/storage.d.ts +26 -0
- package/dist/types/data/transport.d.ts +69 -0
- package/dist/types/flow-document.d.ts +19 -0
- package/dist/types/flow-editor.d.ts +88 -0
- package/docs/EDITORS.md +158 -0
- package/docs/PROJECT-FORMAT.md +1 -11
- package/package.json +32 -15
- package/src/component/data/actions.js +138 -0
- package/src/component/data/index.js +10 -0
- package/src/component/data/mount.js +63 -0
- package/src/component/data/project-widget.js +164 -0
- package/src/component/data/viewmodel.js +185 -0
- package/src/component/data/views.js +241 -0
- package/src/component/document.js +344 -0
- package/src/component/flow/actions.js +331 -0
- package/src/component/flow/index.js +8 -0
- package/src/component/flow/mount.js +53 -0
- package/src/component/flow/project-widget.js +44 -0
- package/src/component/flow/runtime.js +481 -0
- package/src/component/flow/views.js +196 -0
- package/src/component/host.js +2 -2
- package/src/component/index.js +19 -9
- package/src/component/mount.js +43 -0
- package/src/component/project-actions.js +189 -0
- package/src/component/project-controller.js +248 -0
- package/src/component/project-state.js +30 -0
- package/src/component/project.js +308 -0
- package/src/component/shared/host-widget.js +35 -0
- package/src/component/shared/memo.js +28 -0
- package/src/component/shared/nodes.js +94 -0
- package/src/component/shared/schema-options.js +30 -0
- package/src/component/shared/studio-kit.js +59 -0
- package/src/component/shared/ui.js +134 -0
- package/src/data/boot-stages.js +202 -0
- package/src/data/browser-worker.js +247 -0
- package/src/data/contract.js +7 -0
- package/src/data/editor.js +95 -0
- package/src/data/handlers.js +349 -0
- package/src/data/host.js +8 -0
- package/src/data/project-worker.js +26 -0
- package/src/data/runtime.js +462 -0
- package/src/data/state.js +46 -0
- package/src/data/storage.js +61 -0
- package/src/data/transport.js +215 -0
- package/src/flow-document.js +24 -0
- package/src/flow-editor.js +98 -0
- package/styles/data.css +53 -0
- package/styles/editor.css +150 -0
- package/styles/flow.css +99 -0
- package/styles/studio.css +1 -0
- package/dist/types/author.d.ts +0 -27
- package/src/author.js +0 -55
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarenjs/studio",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.84.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"types": "./dist/types/index.d.ts",
|
|
@@ -18,21 +18,34 @@
|
|
|
18
18
|
"./schemas/*": "./schemas/*",
|
|
19
19
|
"./styles/studio.css": "./styles/studio.css",
|
|
20
20
|
"./package.json": "./package.json",
|
|
21
|
-
"./author": {
|
|
22
|
-
"types": "./dist/types/author.d.ts",
|
|
23
|
-
"default": "./src/author.js"
|
|
24
|
-
},
|
|
25
21
|
"./export": {
|
|
26
22
|
"types": "./dist/types/export.d.ts",
|
|
27
23
|
"default": "./src/export.js"
|
|
28
|
-
}
|
|
24
|
+
},
|
|
25
|
+
"./flow": {
|
|
26
|
+
"types": "./dist/types/component/flow/index.d.ts",
|
|
27
|
+
"default": "./src/component/flow/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./styles/flow.css": "./styles/flow.css",
|
|
30
|
+
"./styles/editor.css": "./styles/editor.css",
|
|
31
|
+
"./data": {
|
|
32
|
+
"types": "./dist/types/component/data/index.d.ts",
|
|
33
|
+
"default": "./src/component/data/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./data/host": {
|
|
36
|
+
"types": "./dist/types/data/host.d.ts",
|
|
37
|
+
"default": "./src/data/host.js"
|
|
38
|
+
},
|
|
39
|
+
"./contracts/data.contract.json": "./contracts/data.contract.json",
|
|
40
|
+
"./styles/data.css": "./styles/data.css"
|
|
29
41
|
},
|
|
30
42
|
"files": [
|
|
31
43
|
"dist/types/",
|
|
32
44
|
"src/",
|
|
33
45
|
"docs/",
|
|
34
46
|
"schemas/",
|
|
35
|
-
"styles/"
|
|
47
|
+
"styles/",
|
|
48
|
+
"contracts/"
|
|
36
49
|
],
|
|
37
50
|
"description": "The jaren project IDE: a multi-file project (schema, view, actions, model, queries, flow) is one document; each file is validated against its own grammar, assembled into runnable artifacts, and edited/hosted by the studio widget. The engine is headless; the component layer is the IDE.",
|
|
38
51
|
"author": "joham",
|
|
@@ -67,13 +80,17 @@
|
|
|
67
80
|
"prepack": "npm run build:types"
|
|
68
81
|
},
|
|
69
82
|
"dependencies": {
|
|
70
|
-
"@jarenjs/core": "^0.
|
|
71
|
-
"@jarenjs/validate": "^0.
|
|
72
|
-
"@jarenjs/json": "^0.
|
|
73
|
-
"@jarenjs/app": "^0.
|
|
74
|
-
"@jarenjs/flow": "^0.
|
|
75
|
-
"@jarenjs/db": "^0.
|
|
76
|
-
"@jarenjs/contract": "^0.
|
|
77
|
-
"@jarenjs/
|
|
83
|
+
"@jarenjs/core": "^0.84.3",
|
|
84
|
+
"@jarenjs/validate": "^0.84.3",
|
|
85
|
+
"@jarenjs/json": "^0.84.3",
|
|
86
|
+
"@jarenjs/app": "^0.84.3",
|
|
87
|
+
"@jarenjs/flow": "^0.84.3",
|
|
88
|
+
"@jarenjs/db": "^0.84.3",
|
|
89
|
+
"@jarenjs/contract": "^0.84.3",
|
|
90
|
+
"@jarenjs/view": "^0.84.3",
|
|
91
|
+
"@jarenjs/forms": "^0.84.3",
|
|
92
|
+
"@jarenjs/formats": "^0.84.3",
|
|
93
|
+
"@jarenjs/charts": "^0.84.3",
|
|
94
|
+
"@jarenjs/mermaid": "^0.84.3"
|
|
78
95
|
}
|
|
79
96
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Shared Data transitions for composed and independently mounted editors. */
|
|
3
|
+
export const DATA_ACTIONS = {
|
|
4
|
+
// the data studio (boundaries/data.js): boot the owner worker, edit
|
|
5
|
+
// the model/query panes, run + explain, insert, live-event, migrate.
|
|
6
|
+
// A patch-only action carries its changed paths to the O(k) renderer.
|
|
7
|
+
'data/boot': { effects: [{ run: 'data-boot' }] },
|
|
8
|
+
'data/seed': {
|
|
9
|
+
patch: [
|
|
10
|
+
{ op: 'replace', path: '/data/modelText', value: '$payload.modelText' },
|
|
11
|
+
{ op: 'replace', path: '/data/queryText', value: '$payload.queryText' },
|
|
12
|
+
{ op: 'replace', path: '/data/trip/csv', value: '$payload.tripCsv' },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
// the phone pane switcher (Store · Query · Live) — pure chrome; the
|
|
16
|
+
// store, its live query and its worker are untouched by it
|
|
17
|
+
'data/pane': { patch: [{ op: 'replace', path: '/data/mobilePane', value: '$payload' }] },
|
|
18
|
+
'data/model-text': { patch: [{ op: 'replace', path: '/data/modelText', value: '$event.value' }] },
|
|
19
|
+
'data/query-text': { patch: [{ op: 'replace', path: '/data/queryText', value: '$event.value' }] },
|
|
20
|
+
'data/open': { effects: [{ run: 'data-open', with: { text: '$.data.modelText' } }] },
|
|
21
|
+
'data/run': { effects: [{ run: 'data-run', with: { text: '$.data.queryText' } }] },
|
|
22
|
+
// the title is published per keystroke and read back from state, not
|
|
23
|
+
// from the commit event: a live-query render lands between typing and
|
|
24
|
+
// blurring, and a controlled input whose buffer is not in state is
|
|
25
|
+
// reset to it — silently, taking the insert with it
|
|
26
|
+
'data/insert-draft': { patch: [{ op: 'replace', path: '/data/insertDraft', value: '$event.value' }] },
|
|
27
|
+
'data/insert': {
|
|
28
|
+
patch: [{ op: 'replace', path: '/data/insertDraft', value: '' }],
|
|
29
|
+
effects: [{ run: 'data-insert', with: { title: '$event.value' } }],
|
|
30
|
+
},
|
|
31
|
+
// one stored document removed, by the key its own model declares
|
|
32
|
+
'data/delete': { effects: [{ run: 'data-delete', with: { key: '$payload' } }] },
|
|
33
|
+
'data/migrate': { effects: [{ run: 'data-migrate' }] },
|
|
34
|
+
// the third runner of the spatial corpus, driven from the store pane;
|
|
35
|
+
// the report replaces the last one, and `running` is what shows while
|
|
36
|
+
// the entries are still crossing the worker
|
|
37
|
+
'data/oracle-run': { effects: [{ run: 'data-oracle' }] },
|
|
38
|
+
'data/oracle': { patch: [{ op: 'replace', path: '/data/oracle', value: '$payload' }] },
|
|
39
|
+
// the spatial round trip, driven from the fourth card: the CSV is the
|
|
40
|
+
// editable input, the report replaces the last one, and `running`
|
|
41
|
+
// shows while the throwaway store is crossing the worker
|
|
42
|
+
'data/trip-csv': { patch: [{ op: 'replace', path: '/data/trip/csv', value: '$event.value' }] },
|
|
43
|
+
'data/trip-run': { effects: [{ run: 'data-trip', with: { csv: '$.data.trip.csv' } }] },
|
|
44
|
+
'data/trip': { patch: [{ op: 'replace', path: '/data/trip/report', value: '$payload' }] },
|
|
45
|
+
'data/status': {
|
|
46
|
+
patch: [
|
|
47
|
+
{ op: 'replace', path: '/data/topology', value: '$payload.topology' },
|
|
48
|
+
{ op: 'replace', path: '/data/vfs', value: '$payload.vfs' },
|
|
49
|
+
{ op: 'replace', path: '/data/version', value: { $default: ['$payload.version', ''] } },
|
|
50
|
+
{ op: 'replace', path: '/data/refusal', value: { $default: ['$payload.refusal', null] } },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
'data/opened': {
|
|
54
|
+
patch: [
|
|
55
|
+
{ op: 'replace', path: '/data/status', value: 'ready' },
|
|
56
|
+
// a reopen may have moved the studio onto another model's first
|
|
57
|
+
// collection; the boot open stays on the seed model's
|
|
58
|
+
{ op: 'replace', path: '/data/collection',
|
|
59
|
+
value: { $default: ['$payload.collection', '$.data.collection'] } },
|
|
60
|
+
{ op: 'replace', path: '/data/keyPointer',
|
|
61
|
+
value: { $default: ['$payload.keyPointer', '$.data.keyPointer'] } },
|
|
62
|
+
{ op: 'replace', path: '/data/capture', value: '$payload.capabilities.capture' },
|
|
63
|
+
{ op: 'replace', path: '/data/version', value: '$payload.capabilities.version' },
|
|
64
|
+
{ op: 'replace', path: '/data/operators',
|
|
65
|
+
value: { $default: ['$payload.capabilities.operators', []] } },
|
|
66
|
+
{ op: 'replace', path: '/data/pushableOperators',
|
|
67
|
+
value: { $default: ['$payload.capabilities.pushableOperators', []] } },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
'data/rows': { patch: [{ op: 'replace', path: '/data/rows', value: '$payload.rows' }] },
|
|
71
|
+
'data/results': {
|
|
72
|
+
patch: [
|
|
73
|
+
{ op: 'replace', path: '/data/results', value: '$payload.results' },
|
|
74
|
+
{ op: 'replace', path: '/data/explain', value: '$payload.explain' },
|
|
75
|
+
{ op: 'replace', path: '/data/error', value: null },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
'data/live': {
|
|
79
|
+
patch: [
|
|
80
|
+
{ op: 'replace', path: '/data/live/rows', value: '$payload.rows' },
|
|
81
|
+
{ op: 'replace', path: '/data/live/seq', value: null },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
'data/live-event': {
|
|
85
|
+
patch: [
|
|
86
|
+
{ op: 'replace', path: '/data/live/rows', value: '$payload.rows' },
|
|
87
|
+
{ op: 'replace', path: '/data/live/seq', value: '$payload.seq' },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
// the owner's live-registration count (data.lives), refreshed by the
|
|
91
|
+
// boundary after each of its own live events — how a departed tab's
|
|
92
|
+
// released subscription becomes visible
|
|
93
|
+
'data/lives': { patch: [{ op: 'replace', path: '/data/live/regs', value: '$payload.count' }] },
|
|
94
|
+
'data/migrated': { patch: [{ op: 'replace', path: '/data/migration', value: '$payload.report' }] },
|
|
95
|
+
'data/error': { patch: [{ op: 'replace', path: '/data/error', value: '$payload.message' }] },
|
|
96
|
+
// the boot protocol's two terminal states beside `data/opened` (an
|
|
97
|
+
// owner's or a memory tab's first open): a CLIENT tab is ready once it
|
|
98
|
+
// is attached, and a failed stage is the named, stable error record
|
|
99
|
+
'data/ready': { patch: [{ op: 'replace', path: '/data/status', value: 'ready' }] },
|
|
100
|
+
'data/boot-error': {
|
|
101
|
+
patch: [
|
|
102
|
+
{ op: 'replace', path: '/data/status', value: 'error' },
|
|
103
|
+
{ op: 'replace', path: '/data/boot', value: '$payload' },
|
|
104
|
+
{ op: 'replace', path: '/data/error', value: '$payload.message' },
|
|
105
|
+
// nothing about the store is known after a failed boot
|
|
106
|
+
{ op: 'replace', path: '/data/topology', value: '—' },
|
|
107
|
+
{ op: 'replace', path: '/data/vfs', value: '—' },
|
|
108
|
+
{ op: 'replace', path: '/data/refusal', value: null },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
'data/retry': {
|
|
112
|
+
patch: [
|
|
113
|
+
{ op: 'replace', path: '/data/status', value: 'boot' },
|
|
114
|
+
{ op: 'replace', path: '/data/boot', value: null },
|
|
115
|
+
{ op: 'replace', path: '/data/error', value: null },
|
|
116
|
+
{ op: 'replace', path: '/data/topology', value: '—' },
|
|
117
|
+
{ op: 'replace', path: '/data/vfs', value: '—' },
|
|
118
|
+
{ op: 'replace', path: '/data/refusal', value: null },
|
|
119
|
+
],
|
|
120
|
+
effects: [{ run: 'data-retry' }],
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
DATA_ACTIONS['data/resume'] = { patch: [{ op: 'replace', path: '/data/status', value: 'boot' }], effects: [{ run: 'data-resume' }] };
|
|
126
|
+
|
|
127
|
+
DATA_ACTIONS['data/replace'] = { $if: [
|
|
128
|
+
{ $eq: [{ modelText: '$.data.modelText', queryText: '$.data.queryText' }, '$payload.expected'] },
|
|
129
|
+
{ patch: [
|
|
130
|
+
{ op: 'replace', path: '/data/modelText', value: '$payload.modelText' },
|
|
131
|
+
{ op: 'replace', path: '/data/queryText', value: '$payload.queryText' },
|
|
132
|
+
], effects: [{ run: 'data-accepted', with: '$payload.requestId' }] },
|
|
133
|
+
{ effects: [{ run: 'data-refused', with: '$payload.requestId' }] },
|
|
134
|
+
] };
|
|
135
|
+
DATA_ACTIONS['data/execute'] = { effects: [{ run: 'data-editor-run', with: {
|
|
136
|
+
requestId: '$payload.requestId', operation: '$payload.operation', externals: '$payload.externals',
|
|
137
|
+
text: { $if: [{ $eq: ['$payload.operation', 'open'] }, '$.data.modelText', '$.data.queryText'] },
|
|
138
|
+
} }] };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
export { createTransport } from '../../data/transport.js';
|
|
3
|
+
export { createDataRuntime } from '../../data/runtime.js';
|
|
4
|
+
export { createDataState } from '../../data/state.js';
|
|
5
|
+
export { DATA_ACTIONS } from './actions.js';
|
|
6
|
+
export { DATA_RULES } from './views.js';
|
|
7
|
+
export { dataViewModel, oracleSummary } from './viewmodel.js';
|
|
8
|
+
export { createProjectDataRuntime } from './project-widget.js';
|
|
9
|
+
export { createDataController } from '../../data/editor.js';
|
|
10
|
+
export { mountDataEditor } from './mount.js';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Mount the same Data component used by composed hosts, with an injected transport. */
|
|
3
|
+
import { createApp, formEventFields } from '@jarenjs/app';
|
|
4
|
+
import { createDataRuntime } from '../../data/runtime.js';
|
|
5
|
+
import { createDataController } from '../../data/editor.js';
|
|
6
|
+
import { createDataState } from '../../data/state.js';
|
|
7
|
+
import { DATA_ACTIONS } from './actions.js';
|
|
8
|
+
import { DATA_RULES } from './views.js';
|
|
9
|
+
import { dataViewModel } from './viewmodel.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {HTMLElement|null} node
|
|
13
|
+
* @param {{ model: any, query: any, transport: () => ReturnType<typeof import('../../data/transport.js').createTransport>,
|
|
14
|
+
* seeds?: any[], createRow?: (text: string) => any, migration?: (model: any, collection: string) => { to: any, id: string },
|
|
15
|
+
* trip?: any, corpus?: () => Promise<any>, executor?: string, operators?: { toOptions: () => any }, lifecycleTarget?: any,
|
|
16
|
+
* schedule?: (flush: () => void) => void, onError?: (error: Error) => void }} options
|
|
17
|
+
*/
|
|
18
|
+
export function mountDataEditor(node, options) {
|
|
19
|
+
let app, settleReady, disposed = false, active = true;
|
|
20
|
+
/** @type {Promise<{ ok: boolean, error?: string | { code: string, stage: string, message: string } }>} */
|
|
21
|
+
let ready = new Promise(resolve => { settleReady = resolve; });
|
|
22
|
+
const runtime = createDataRuntime(options), controller = createDataController({ ...options, getApp: () => app, runtime });
|
|
23
|
+
app = createApp({
|
|
24
|
+
state: { active: true, data: createDataState() }, actions: { ...DATA_ACTIONS,
|
|
25
|
+
'data/active': { patch: [{ op: 'replace', path: '/active', value: '$payload' },
|
|
26
|
+
{ op: 'replace', path: '/data/status', value: { $if: ['$payload', 'boot', '$.data.status'] } }] },
|
|
27
|
+
}, subs: [{ run: 'data-owner', when: '$.active' }],
|
|
28
|
+
view: { $jslt: '0.1', modes: { data: { unmatched: 'error' } }, rules: [
|
|
29
|
+
{ match: '$', body: { $apply: ['$.ui.data', 'data'] } }, ...DATA_RULES,
|
|
30
|
+
] },
|
|
31
|
+
}, {
|
|
32
|
+
node, document: node?.ownerDocument, schedule: options.schedule, onError: options.onError,
|
|
33
|
+
eventFields: formEventFields(), effects: { ...runtime.effects, ...controller.effects },
|
|
34
|
+
subs: { 'data-owner': runtime.ownerSub }, viewModel: state => ({ ui: { data: dataViewModel(state, options) } }),
|
|
35
|
+
});
|
|
36
|
+
const stopReady = app.subscribe(state => {
|
|
37
|
+
if (!state.active) return;
|
|
38
|
+
if (state.data.status === 'ready') settleReady({ ok: true });
|
|
39
|
+
else if (state.data.status === 'error') settleReady({ ok: false, error: state.data.boot });
|
|
40
|
+
});
|
|
41
|
+
controller.attach();
|
|
42
|
+
return { get ready() { return ready; }, read: controller.read, validate: controller.validate, replace: controller.replace,
|
|
43
|
+
apply: controller.apply,
|
|
44
|
+
/** @param {Parameters<typeof controller.run>[0]} [options] */
|
|
45
|
+
run(options = {}) { return active && !disposed ? controller.run(options)
|
|
46
|
+
: Promise.resolve({ ok: false, error: disposed ? 'The Data editor is disposed.' : 'The Data editor is inactive.' }); },
|
|
47
|
+
subscribe: controller.subscribe,
|
|
48
|
+
/** Release ownership on route exit while retaining every raw editing buffer.
|
|
49
|
+
* Reactivation reopens the last accepted model and never seeds over typing.
|
|
50
|
+
* @param {boolean} next */
|
|
51
|
+
setActive(next) {
|
|
52
|
+
if (disposed) return Promise.resolve({ ok: false, error: 'The Data editor is disposed.' });
|
|
53
|
+
if (next === active) return ready;
|
|
54
|
+
active = next;
|
|
55
|
+
settleReady({ ok: false, error: 'The Data editor is inactive.' });
|
|
56
|
+
ready = next ? new Promise(resolve => { settleReady = resolve; })
|
|
57
|
+
: Promise.resolve({ ok: false, error: 'The Data editor is inactive.' });
|
|
58
|
+
app.dispatch('data/active', next);
|
|
59
|
+
return ready;
|
|
60
|
+
},
|
|
61
|
+
dispose() { if (disposed) return; disposed = true; settleReady({ ok: false, error: 'The Data editor is disposed.' }); stopReady(); controller.dispose(); app.destroy(); },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Project-owned model workers. File switches release views/subscriptions;
|
|
3
|
+
* model changes, deletion, project replacement and app teardown release stores. */
|
|
4
|
+
import { openPortClient } from '@jarenjs/contract/port';
|
|
5
|
+
import { createDomRenderer } from '@jarenjs/view';
|
|
6
|
+
import { semanticKey } from '@jarenjs/core/object';
|
|
7
|
+
import { applyJSONPatch } from '@jarenjs/json/patch';
|
|
8
|
+
import { resolveProjectFile, projectFileContext } from '../../index.js';
|
|
9
|
+
import { dataContract as contract } from '../../data/contract.js';
|
|
10
|
+
|
|
11
|
+
const SCAN = [{ $for: { row: '$[*]' }, $return: '$row' }];
|
|
12
|
+
const unwrap = (outcome) => {
|
|
13
|
+
if (outcome.ok) return outcome.value;
|
|
14
|
+
throw Object.assign(new Error(outcome.error.details?.message ?? outcome.error.message), {
|
|
15
|
+
code: outcome.error.details?.code ?? outcome.error.code,
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function createProjectDataRuntime(options = {}) {
|
|
20
|
+
const workers = new Map();
|
|
21
|
+
const retire = (name) => {
|
|
22
|
+
const entry = workers.get(name);
|
|
23
|
+
if (!entry) return;
|
|
24
|
+
workers.delete(name);
|
|
25
|
+
entry.client.close(); entry.worker.terminate();
|
|
26
|
+
};
|
|
27
|
+
const dispose = () => { for (const name of [...workers.keys()]) retire(name); };
|
|
28
|
+
const sync = (project, reset = false) => {
|
|
29
|
+
if (reset) { dispose(); return; }
|
|
30
|
+
for (const name of workers.keys()) {
|
|
31
|
+
if (!project.files.some((f) => f.name === name && f.kind === 'model')) retire(name);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const acquire = (project, file) => {
|
|
35
|
+
const route = projectFileContext(project, file);
|
|
36
|
+
const modelFile = file.kind === 'model' ? file : route.model;
|
|
37
|
+
if (!modelFile) throw new Error('Choose a model file for this query.');
|
|
38
|
+
const model = resolveProjectFile(project, modelFile.name).doc;
|
|
39
|
+
const names = Object.keys(model.collections ?? {});
|
|
40
|
+
const collection = route.collection ?? (names.length === 1 ? names[0] : null);
|
|
41
|
+
if (collection === null || !names.includes(collection))
|
|
42
|
+
throw new Error('Choose a collection from the model in the file routing controls.');
|
|
43
|
+
let seed = null;
|
|
44
|
+
if (modelFile.input !== undefined) {
|
|
45
|
+
const source = projectFileContext(project, modelFile).input;
|
|
46
|
+
seed = JSON.parse(source.text);
|
|
47
|
+
if (!seed || typeof seed !== 'object' || Array.isArray(seed)
|
|
48
|
+
|| Object.entries(seed).some(([name, rows]) => !names.includes(name) || !Array.isArray(rows)))
|
|
49
|
+
throw new Error('A model seed must be an object mapping collection names to arrays of documents.');
|
|
50
|
+
}
|
|
51
|
+
const key = semanticKey({ model, seed });
|
|
52
|
+
let entry = workers.get(modelFile.name);
|
|
53
|
+
if (entry && entry.key !== key) { retire(modelFile.name); entry = null; }
|
|
54
|
+
if (!entry) {
|
|
55
|
+
if (!options.createWorker) throw new Error('This project host has no model worker factory.');
|
|
56
|
+
const worker = options.createWorker();
|
|
57
|
+
const client = openPortClient(contract, { channel: worker, timeoutMs: 15_000 });
|
|
58
|
+
const request = async (op, args) => unwrap(await client.invoke(op, args));
|
|
59
|
+
entry = { worker, client, key, request, ready: null };
|
|
60
|
+
workers.set(modelFile.name, entry);
|
|
61
|
+
const owned = entry;
|
|
62
|
+
entry.ready = request('data.init', null).then(async () => {
|
|
63
|
+
const opened = await request('data.open', { model });
|
|
64
|
+
for (const [collection, rows] of Object.entries(seed ?? {}))
|
|
65
|
+
for (const doc of rows) await request('data.insert', { collection, doc });
|
|
66
|
+
return opened;
|
|
67
|
+
})
|
|
68
|
+
.catch((error) => {
|
|
69
|
+
if (workers.get(modelFile.name) === owned) retire(modelFile.name);
|
|
70
|
+
throw error;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return { entry, collection, modelName: modelFile.name };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const draw = (h) => {
|
|
77
|
+
if (h.dead) return;
|
|
78
|
+
h.render(['section', { class: 'project-data' },
|
|
79
|
+
['p', {}, 'Private SQLite store · ', h.modelName ?? '',
|
|
80
|
+
' · rows last until the model or seed changes, or the project closes.'],
|
|
81
|
+
['p', { role: 'status', class: 'project-data-status' }, h.status],
|
|
82
|
+
['label', {}, 'Insert document (JSON)', ['textarea', {
|
|
83
|
+
class: 'editor project-data-insert', 'aria-label': 'Insert document (JSON)',
|
|
84
|
+
value: h.draft, on: { input: 'draft' }, rows: 4,
|
|
85
|
+
}]],
|
|
86
|
+
['button', { class: 'btn', type: 'button', on: { click: 'insert' }, disabled: !h.ready }, 'Insert row'],
|
|
87
|
+
['label', {}, 'Delete key (JSON)', ['input', {
|
|
88
|
+
class: 'editor line', 'aria-label': 'Delete key (JSON)', value: h.deleteDraft, on: { input: 'delete-draft' },
|
|
89
|
+
}]],
|
|
90
|
+
['button', { class: 'btn', type: 'button', on: { click: 'delete' }, disabled: !h.ready }, 'Delete row'],
|
|
91
|
+
['h3', {}, 'Result'], ['pre', { class: 'project-data-result' }, JSON.stringify(h.result, null, 2)],
|
|
92
|
+
['h3', {}, 'Live result'], ['pre', { class: 'project-data-live' }, JSON.stringify(h.live, null, 2)],
|
|
93
|
+
['details', {}, ['summary', {}, 'Query plan'],
|
|
94
|
+
['pre', { class: 'project-data-plan' }, JSON.stringify(h.plan, null, 2)]],
|
|
95
|
+
]);
|
|
96
|
+
};
|
|
97
|
+
const start = async (h, props) => {
|
|
98
|
+
h.subscription?.stop();
|
|
99
|
+
const generation = ++h.generation;
|
|
100
|
+
const current = () => !h.dead && h.generation === generation;
|
|
101
|
+
h.props = props; h.ready = false; h.status = 'Opening SQLite…'; draw(h);
|
|
102
|
+
try {
|
|
103
|
+
const project = { files: props.files };
|
|
104
|
+
const file = props.files.find((f) => f.name === props.name);
|
|
105
|
+
const { entry, collection, modelName } = acquire(project, file);
|
|
106
|
+
h.modelName = modelName;
|
|
107
|
+
await entry.ready;
|
|
108
|
+
if (!current()) return;
|
|
109
|
+
h.entry = entry; h.collection = collection;
|
|
110
|
+
const document = file.kind === 'model' ? SCAN : resolveProjectFile(project, file.name).doc;
|
|
111
|
+
const args = { collection, document };
|
|
112
|
+
const result = await entry.request('data.execute', args);
|
|
113
|
+
const plan = await entry.request('data.explain', args);
|
|
114
|
+
if (!current()) return;
|
|
115
|
+
h.result = result; h.plan = plan; h.ready = true; h.status = 'Ready'; draw(h);
|
|
116
|
+
h.subscription = entry.client.subscribe('data.live', args, {
|
|
117
|
+
onSnapshot: (value) => { if (current()) { h.live = value; draw(h); } },
|
|
118
|
+
onPatch: ({ patch }) => { if (current()) { h.live = applyJSONPatch(h.live, patch); draw(h); } },
|
|
119
|
+
onError: (outcome) => {
|
|
120
|
+
if (current()) { h.status = `Live query: ${outcome.error.details?.message ?? outcome.error.message}`; draw(h); }
|
|
121
|
+
},
|
|
122
|
+
onEnd: ({ reason }) => { if (current()) { h.status = `Live query ended: ${reason}`; draw(h); } },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (error) { if (current()) { h.status = String(error.message ?? error); draw(h); } }
|
|
126
|
+
};
|
|
127
|
+
const widget = {
|
|
128
|
+
mount(host, props) {
|
|
129
|
+
const h = { dead: false, generation: 0, draft: '{"id":"n1","title":"New note","points":20}',
|
|
130
|
+
deleteDraft: '"n1"', result: null, plan: null, live: null, props };
|
|
131
|
+
h.render = createDomRenderer(host, { onEvent: async (binding, event) => {
|
|
132
|
+
if (binding === 'draft') { h.draft = event.target.value; return; }
|
|
133
|
+
if (binding === 'delete-draft') { h.deleteDraft = event.target.value; return; }
|
|
134
|
+
if (!h.ready) return;
|
|
135
|
+
const generation = h.generation;
|
|
136
|
+
try {
|
|
137
|
+
const value = JSON.parse(binding === 'insert' ? h.draft : h.deleteDraft);
|
|
138
|
+
await h.entry.request(binding === 'insert' ? 'data.insert' : 'data.delete', {
|
|
139
|
+
collection: h.collection, ...(binding === 'insert' ? { doc: value } : { key: value }),
|
|
140
|
+
});
|
|
141
|
+
if (h.dead || h.generation !== generation) return;
|
|
142
|
+
h.status = 'Committed'; draw(h);
|
|
143
|
+
}
|
|
144
|
+
catch (error) { if (!h.dead && h.generation === generation) { h.status = error.message; draw(h); } }
|
|
145
|
+
} });
|
|
146
|
+
void start(h, props); return h;
|
|
147
|
+
},
|
|
148
|
+
update(h, props) {
|
|
149
|
+
if (h.props.files === props.files && h.props.name === props.name && h.props.revision === props.revision) return;
|
|
150
|
+
void start(h, props);
|
|
151
|
+
},
|
|
152
|
+
unmount(h) { h.dead = true; h.generation++; h.subscription?.stop(); h.render.destroy(); },
|
|
153
|
+
};
|
|
154
|
+
const execute = async (project, name) => {
|
|
155
|
+
const file = project.files.find((f) => f.name === name);
|
|
156
|
+
const { entry, collection } = acquire(project, file);
|
|
157
|
+
await entry.ready;
|
|
158
|
+
const document = file.kind === 'model' ? SCAN : resolveProjectFile(project, name).doc;
|
|
159
|
+
const args = { collection, document };
|
|
160
|
+
const result = await entry.request('data.execute', args);
|
|
161
|
+
return { result, plan: await entry.request('data.explain', args) };
|
|
162
|
+
};
|
|
163
|
+
return { widget, sync, dispose, acquire, execute };
|
|
164
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
import { pickAllowed } from '@jarenjs/core/array';
|
|
3
|
+
import { compileJSONPointer, JSONPOINTER_NOTHING } from '@jarenjs/json/pointer';
|
|
4
|
+
const DATA_PANES = ['store', 'query', 'live', 'trip'];
|
|
5
|
+
/** One compiled key getter per pointer the models have named: the row
|
|
6
|
+
* list is rebuilt on every live event, and compiling per render would
|
|
7
|
+
* pay for the pointer over and over. */
|
|
8
|
+
const keyGetters = new Map();
|
|
9
|
+
|
|
10
|
+
/** @param {string} pointer */
|
|
11
|
+
function keyGetter(pointer) {
|
|
12
|
+
let get = keyGetters.get(pointer);
|
|
13
|
+
if (get === undefined) {
|
|
14
|
+
get = compileJSONPointer(pointer);
|
|
15
|
+
keyGetters.set(pointer, get);
|
|
16
|
+
}
|
|
17
|
+
return get;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The stored documents as a deletable list. The key comes from the
|
|
22
|
+
* pointer the MODEL declares, not from a member name written here, so a
|
|
23
|
+
* reader who renames the key in the model pane still gets rows they can
|
|
24
|
+
* remove. A document the pointer misses is listed without a key and its
|
|
25
|
+
* control is left out — a delete with nothing to address would be a
|
|
26
|
+
* button that quietly does nothing.
|
|
27
|
+
* @param {any[]} rows
|
|
28
|
+
* @param {string} pointer
|
|
29
|
+
*/
|
|
30
|
+
function rowList(rows, pointer) {
|
|
31
|
+
const get = keyGetter(pointer);
|
|
32
|
+
return rows.map((doc, index) => {
|
|
33
|
+
const key = get(doc);
|
|
34
|
+
const found = key !== JSONPOINTER_NOTHING;
|
|
35
|
+
return {
|
|
36
|
+
index,
|
|
37
|
+
text: JSON.stringify(doc),
|
|
38
|
+
...(found ? { key } : {}),
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The oracle's verdict, as one line: what ran, what agreed, what was
|
|
45
|
+
* left out by its marker — or why there is no verdict.
|
|
46
|
+
* @param {any} oracle
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function oracleSummary(oracle) {
|
|
50
|
+
if (oracle === null || oracle === undefined) return '';
|
|
51
|
+
if (oracle.status === 'running') return 'running the spatial corpus through this tab\u2019s store\u2026';
|
|
52
|
+
if (oracle.status === 'error') return `the spatial corpus could not run here: ${oracle.message}`;
|
|
53
|
+
const verdict = oracle.agreed === oracle.ran
|
|
54
|
+
? `${oracle.executor} agreed with the engine on every entry: ${oracle.agreed} / ${oracle.ran}`
|
|
55
|
+
: `${oracle.executor} DISAGREED with the engine on ${oracle.ran - oracle.agreed} of ${oracle.ran}`;
|
|
56
|
+
return `${verdict} (${oracle.entries} entries \u00d7 ${oracle.mappings.join(', ')});`
|
|
57
|
+
+ ` ${oracle.skipped.length} engine-only entries left out by their marker`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The page's view model — the boundary-exports convention.
|
|
62
|
+
* @param {any} state
|
|
63
|
+
* @param {any} [options]
|
|
64
|
+
*/
|
|
65
|
+
export function dataViewModel(state, options = {}) {
|
|
66
|
+
const TRIP_TEXTS = options.trip?.texts ?? {}, TRIP_CHAIN_TEXT = options.trip?.chainText ?? "";
|
|
67
|
+
const tripSummary = options.trip?.summary ?? (() => ""), tripMap = options.trip?.map ?? (() => null);
|
|
68
|
+
const data = state.data;
|
|
69
|
+
return {
|
|
70
|
+
collection: data.collection,
|
|
71
|
+
insertPlaceholder: options.createRow ? `new ${data.collection} title… (enter inserts)` : 'new document as JSON… (enter inserts)',
|
|
72
|
+
tripAvailable: !!options.trip,
|
|
73
|
+
oracleAvailable: typeof options.corpus === 'function' || options.features?.oracle === true,
|
|
74
|
+
migrationAvailable: typeof options.migration === 'function',
|
|
75
|
+
status: data.status,
|
|
76
|
+
boot: data.boot,
|
|
77
|
+
// an operational error line shows beside a booted studio; a boot
|
|
78
|
+
// failure is rendered by its own card instead
|
|
79
|
+
plainError: data.status === 'error' ? null : data.error,
|
|
80
|
+
topology: data.topology,
|
|
81
|
+
vfs: data.vfs,
|
|
82
|
+
version: data.version,
|
|
83
|
+
capture: data.capture,
|
|
84
|
+
operators: data.operators,
|
|
85
|
+
pushableOperators: data.pushableOperators,
|
|
86
|
+
operatorSummary: data.operators.length > 0
|
|
87
|
+
? `${data.operators.length} registered · ${data.pushableOperators.length} pushed to SQLite as UDFs`
|
|
88
|
+
: data.topology === 'client' && data.capture === '—' ? 'the owner\'s (awaiting capabilities)'
|
|
89
|
+
: data.status === 'ready' ? 'none registered' : '—',
|
|
90
|
+
operatorList: data.operators.join(' ') || '—',
|
|
91
|
+
// a copyable query that exercises both paths: $sqrt is a pushable
|
|
92
|
+
// scalar (a wasm UDF — watch explain() show jaren_p_ in the SQL),
|
|
93
|
+
// $mean folds a series in the residual (explain names it)
|
|
94
|
+
operatorSample: JSON.stringify({
|
|
95
|
+
$for: { it: '$[*]' },
|
|
96
|
+
$where: { $gt: [{ $sqrt: '$it.points' }, 4] },
|
|
97
|
+
$return: '$it',
|
|
98
|
+
}, null, 2),
|
|
99
|
+
refusal: data.refusal,
|
|
100
|
+
// a verdict only once the topology is known: during the boot, and
|
|
101
|
+
// after a boot failure, nothing about OPFS has been learned
|
|
102
|
+
liveNote: data.vfs === 'indexeddb-snapshot' ? 'Live queries are unavailable while writes await durable snapshots. The Store pane refreshes after each write.' : '',
|
|
103
|
+
durability: data.vfs === 'opfs-sab'
|
|
104
|
+
? 'persistent (OPFS with SharedArrayBuffer and cross-origin isolation)'
|
|
105
|
+
: data.vfs === 'indexeddb-snapshot'
|
|
106
|
+
? 'persistent (atomic IndexedDB snapshots; writes await storage)'
|
|
107
|
+
: data.vfs === 'opfs-sahpool'
|
|
108
|
+
? 'persistent (OPFS access-handle pool, no special headers)'
|
|
109
|
+
: data.vfs === 'memory' ? 'in-memory (non-durable — persistent storage is unavailable; data lives until reload)'
|
|
110
|
+
: 'not decided yet — the store has not booted',
|
|
111
|
+
modelText: data.modelText,
|
|
112
|
+
queryText: data.queryText,
|
|
113
|
+
rows: data.rows,
|
|
114
|
+
rowCount: data.rows.length,
|
|
115
|
+
rowList: rowList(data.rows, data.keyPointer),
|
|
116
|
+
rowSummary: `${data.rows.length} stored in ${data.collection}`,
|
|
117
|
+
results: data.results,
|
|
118
|
+
resultsJson: JSON.stringify(data.results, null, 1),
|
|
119
|
+
explain: data.explain === null ? null : {
|
|
120
|
+
sql: data.explain.sql,
|
|
121
|
+
params: JSON.stringify(data.explain.params),
|
|
122
|
+
indexes: data.explain.indexes.join(', ') || '(none)',
|
|
123
|
+
residual: data.explain.residual === null
|
|
124
|
+
? 'none — fully pushed to SQL'
|
|
125
|
+
: JSON.stringify(data.explain.residual.reasons?.map((reason) => reason.construct)),
|
|
126
|
+
},
|
|
127
|
+
live: data.live,
|
|
128
|
+
liveSummary: `${data.live.rows.length} rows, seq ${data.live.seq ?? 0}`,
|
|
129
|
+
liveRegs: data.live.regs === null ? '—' : String(data.live.regs),
|
|
130
|
+
liveJson: JSON.stringify(data.live.rows, null, 1),
|
|
131
|
+
insertDraft: data.insertDraft,
|
|
132
|
+
migration: data.migration,
|
|
133
|
+
migrationSteps: data.migration === null ? ''
|
|
134
|
+
: data.migration.planned.join('\n'),
|
|
135
|
+
migrationSummary: data.migration === null ? ''
|
|
136
|
+
: `applied: ${data.migration.applied.length}`
|
|
137
|
+
+ (data.migration.note ? ` — ${data.migration.note}` : ''),
|
|
138
|
+
// the spatial-corpus run: its verdict as one line, the disagreements
|
|
139
|
+
// as lines that name the executor, the entry and the query, and every
|
|
140
|
+
// answer as JSON for the reader (and the e2e spec) to check
|
|
141
|
+
oracle: data.oracle,
|
|
142
|
+
oracleDone: data.oracle?.status === 'done',
|
|
143
|
+
oracleDisagreed: (data.oracle?.disagreements?.length ?? 0) > 0,
|
|
144
|
+
oracleSummary: oracleSummary(data.oracle),
|
|
145
|
+
oracleDisagreements: (data.oracle?.disagreements ?? []).join('\n'),
|
|
146
|
+
oracleResultsJson: data.oracle?.status === 'done' ? JSON.stringify({
|
|
147
|
+
executor: data.oracle.executor,
|
|
148
|
+
vfs: data.vfs,
|
|
149
|
+
source: data.oracle.source,
|
|
150
|
+
ran: data.oracle.ran,
|
|
151
|
+
agreed: data.oracle.agreed,
|
|
152
|
+
skipped: data.oracle.skipped,
|
|
153
|
+
results: data.oracle.results,
|
|
154
|
+
}) : '',
|
|
155
|
+
// the spatial round trip: the editable CSV, the constant halves a
|
|
156
|
+
// reader can open (the stylesheet, the model, the chain and the
|
|
157
|
+
// document it emits), and the report — its explain() members named
|
|
158
|
+
// one by one so the two stages are legible, and the map drawn
|
|
159
|
+
// through the geojson format's preview hint
|
|
160
|
+
tripCsv: data.trip.csv,
|
|
161
|
+
tripStylesheet: TRIP_TEXTS.stylesheet,
|
|
162
|
+
tripModel: TRIP_TEXTS.model,
|
|
163
|
+
tripChain: TRIP_CHAIN_TEXT,
|
|
164
|
+
tripQuery: TRIP_TEXTS.query,
|
|
165
|
+
tripRegion: TRIP_TEXTS.region,
|
|
166
|
+
trip: data.trip.report,
|
|
167
|
+
tripStatus: data.trip.report?.status ?? 'idle',
|
|
168
|
+
tripDone: data.trip.report?.status === 'done',
|
|
169
|
+
tripSummary: tripSummary(data.trip.report),
|
|
170
|
+
tripResultsJson: data.trip.report?.status === 'done'
|
|
171
|
+
? JSON.stringify(data.trip.report.results, null, 1) : '',
|
|
172
|
+
tripExplainSql: data.trip.report?.explain?.sql ?? '',
|
|
173
|
+
tripPrefilters: JSON.stringify(data.trip.report?.explain?.prefilters ?? []),
|
|
174
|
+
tripNarrative: data.trip.report?.explain?.scanNarrative ?? '',
|
|
175
|
+
tripIndexes: (data.trip.report?.explain?.indexes ?? []).join(', ') || '(none)',
|
|
176
|
+
tripResidual: data.trip.report?.explain === undefined || data.trip.report?.explain === null ? ''
|
|
177
|
+
: data.trip.report.explain.residual === null
|
|
178
|
+
? 'none \u2014 fully pushed to SQL'
|
|
179
|
+
: JSON.stringify(data.trip.report.explain.residual.reasons?.map((reason) => reason.construct)),
|
|
180
|
+
tripMap: tripMap(data.trip.report),
|
|
181
|
+
error: data.error,
|
|
182
|
+
// the phone pane (Store · Query · Live · Round trip)
|
|
183
|
+
mobilePane: pickAllowed(data.mobilePane, DATA_PANES, 'query'),
|
|
184
|
+
};
|
|
185
|
+
}
|