@jarenjs/studio 0.83.3 → 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.
Files changed (90) hide show
  1. package/README.md +21 -6
  2. package/contracts/data.contract.json +243 -0
  3. package/dist/types/component/data/actions.d.ts +234 -0
  4. package/dist/types/component/data/index.d.ts +9 -0
  5. package/dist/types/component/data/mount.d.ts +87 -0
  6. package/dist/types/component/data/project-widget.d.ts +27 -0
  7. package/dist/types/component/data/viewmodel.d.ts +85 -0
  8. package/dist/types/component/data/views.d.ts +148 -0
  9. package/dist/types/component/document.d.ts +82 -0
  10. package/dist/types/component/flow/actions.d.ts +708 -0
  11. package/dist/types/component/flow/index.d.ts +7 -0
  12. package/dist/types/component/flow/mount.d.ts +66 -0
  13. package/dist/types/component/flow/project-widget.d.ts +8 -0
  14. package/dist/types/component/flow/runtime.d.ts +57 -0
  15. package/dist/types/component/flow/views.d.ts +242 -0
  16. package/dist/types/component/host.d.ts +2 -2
  17. package/dist/types/component/index.d.ts +21 -15
  18. package/dist/types/component/mount.d.ts +51 -0
  19. package/dist/types/component/project-actions.d.ts +302 -0
  20. package/dist/types/component/project-controller.d.ts +81 -0
  21. package/dist/types/component/project-state.d.ts +1 -0
  22. package/dist/types/component/project.d.ts +327 -0
  23. package/dist/types/component/shared/host-widget.d.ts +24 -0
  24. package/dist/types/component/shared/memo.d.ts +12 -0
  25. package/dist/types/component/shared/nodes.d.ts +92 -0
  26. package/dist/types/component/shared/schema-options.d.ts +28 -0
  27. package/dist/types/component/shared/studio-kit.d.ts +78 -0
  28. package/dist/types/component/shared/ui.d.ts +136 -0
  29. package/dist/types/component/view.d.ts +6 -6
  30. package/dist/types/data/boot-stages.d.ts +97 -0
  31. package/dist/types/data/browser-worker.d.ts +22 -0
  32. package/dist/types/data/contract.d.ts +9 -0
  33. package/dist/types/data/editor.d.ts +81 -0
  34. package/dist/types/data/handlers.d.ts +130 -0
  35. package/dist/types/data/host.d.ts +8 -0
  36. package/dist/types/data/project-worker.d.ts +8 -0
  37. package/dist/types/data/runtime.d.ts +46 -0
  38. package/dist/types/data/state.d.ts +33 -0
  39. package/dist/types/data/storage.d.ts +26 -0
  40. package/dist/types/data/transport.d.ts +69 -0
  41. package/dist/types/flow-document.d.ts +19 -0
  42. package/dist/types/flow-editor.d.ts +88 -0
  43. package/docs/EDITORS.md +158 -0
  44. package/docs/PROJECT-FORMAT.md +1 -11
  45. package/package.json +32 -15
  46. package/src/component/data/actions.js +138 -0
  47. package/src/component/data/index.js +10 -0
  48. package/src/component/data/mount.js +63 -0
  49. package/src/component/data/project-widget.js +164 -0
  50. package/src/component/data/viewmodel.js +185 -0
  51. package/src/component/data/views.js +241 -0
  52. package/src/component/document.js +344 -0
  53. package/src/component/flow/actions.js +331 -0
  54. package/src/component/flow/index.js +8 -0
  55. package/src/component/flow/mount.js +53 -0
  56. package/src/component/flow/project-widget.js +44 -0
  57. package/src/component/flow/runtime.js +481 -0
  58. package/src/component/flow/views.js +196 -0
  59. package/src/component/host.js +2 -2
  60. package/src/component/index.js +19 -9
  61. package/src/component/mount.js +43 -0
  62. package/src/component/project-actions.js +189 -0
  63. package/src/component/project-controller.js +248 -0
  64. package/src/component/project-state.js +30 -0
  65. package/src/component/project.js +308 -0
  66. package/src/component/shared/host-widget.js +35 -0
  67. package/src/component/shared/memo.js +28 -0
  68. package/src/component/shared/nodes.js +94 -0
  69. package/src/component/shared/schema-options.js +30 -0
  70. package/src/component/shared/studio-kit.js +59 -0
  71. package/src/component/shared/ui.js +134 -0
  72. package/src/data/boot-stages.js +202 -0
  73. package/src/data/browser-worker.js +247 -0
  74. package/src/data/contract.js +7 -0
  75. package/src/data/editor.js +95 -0
  76. package/src/data/handlers.js +349 -0
  77. package/src/data/host.js +8 -0
  78. package/src/data/project-worker.js +26 -0
  79. package/src/data/runtime.js +462 -0
  80. package/src/data/state.js +46 -0
  81. package/src/data/storage.js +61 -0
  82. package/src/data/transport.js +215 -0
  83. package/src/flow-document.js +24 -0
  84. package/src/flow-editor.js +98 -0
  85. package/styles/data.css +53 -0
  86. package/styles/editor.css +150 -0
  87. package/styles/flow.css +99 -0
  88. package/styles/studio.css +1 -0
  89. package/dist/types/author.d.ts +0 -27
  90. 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.83.3",
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.83.3",
71
- "@jarenjs/validate": "^0.83.3",
72
- "@jarenjs/json": "^0.83.3",
73
- "@jarenjs/app": "^0.83.3",
74
- "@jarenjs/flow": "^0.83.3",
75
- "@jarenjs/db": "^0.83.3",
76
- "@jarenjs/contract": "^0.83.3",
77
- "@jarenjs/ai": "^0.83.3"
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
+ }