@jarenjs/studio 0.83.3 → 0.85.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The generic render-node vocabulary — mode 'ui'. Boundaries (and the
|
|
4
|
+
* docs content) emit kind-tagged JSON nodes; one rule per kind turns
|
|
5
|
+
* them into vnodes. This is the site's component library, as data:
|
|
6
|
+
* cards, tables, callouts, code blocks, error blocks, bar charts,
|
|
7
|
+
* collapsibles, rendered Markdown articles, a search box and a
|
|
8
|
+
* show-more button.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The rule for one deep-linkable tab in a `nav.tabs` strip. Each page
|
|
13
|
+
* holds its tab entries (`{ href, label, active }`) at a different
|
|
14
|
+
* location, hence the match parameter.
|
|
15
|
+
* @param {string} match - the JSONPath to the tab entries.
|
|
16
|
+
* @param {string} mode - the page's dispatch mode.
|
|
17
|
+
*/
|
|
18
|
+
export const tabRule = (match, mode) => ({
|
|
19
|
+
match, mode,
|
|
20
|
+
body: ['a', {
|
|
21
|
+
href: '$.href',
|
|
22
|
+
class: { $if: ['$.active', 'tab active', 'tab'] },
|
|
23
|
+
}, '$.label'],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const UI_RULES = [
|
|
27
|
+
{
|
|
28
|
+
match: "$..[?@.kind == 'p']", mode: 'ui',
|
|
29
|
+
body: ['p', { class: 'doc-p' }, '$.text'],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
match: "$..[?@.kind == 'cards']", mode: 'ui',
|
|
33
|
+
body: ['div', { class: 'cards' }, [{ $apply: '$.items[*]' }]],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
match: "$..[?@.kind == 'card']", mode: 'ui',
|
|
37
|
+
body: ['div', { class: 'stat-card' },
|
|
38
|
+
['p', { class: 'stat-title' }, '$.title'],
|
|
39
|
+
['p', { class: 'stat-value' }, '$.value'],
|
|
40
|
+
{ $if: ['$.note', ['p', { class: 'stat-note' }, '$.note']] },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
match: "$..[?@.kind == 'table']", mode: 'ui',
|
|
45
|
+
body: ['div', { class: 'table-card' },
|
|
46
|
+
['h3', {}, '$.title'],
|
|
47
|
+
['div', { class: 'table-scroll' },
|
|
48
|
+
['table', {},
|
|
49
|
+
['thead', {}, ['tr', {}, [{ $apply: '$.head[*]' }]]],
|
|
50
|
+
['tbody', {}, [{ $apply: '$.rows[*]' }]],
|
|
51
|
+
],
|
|
52
|
+
],
|
|
53
|
+
{ $if: ['$.note', ['p', { class: 'table-note' }, '$.note']] },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
match: "$..[?@.kind == 'table']..head[*]", mode: 'ui',
|
|
58
|
+
body: ['th', {}, '$'],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
match: "$..[?@.kind == 'row']", mode: 'ui',
|
|
62
|
+
body: ['tr', { class: { $if: ['$.strong', 'strong', ''] } }, [{ $apply: '$.cells[*]' }]],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
match: "$..[?@.kind == 'row']..cells[*]", mode: 'ui',
|
|
66
|
+
body: ['td', {}, '$'],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
match: "$..[?@.kind == 'callout']", mode: 'ui',
|
|
70
|
+
body: ['div', { class: 'callout' },
|
|
71
|
+
['h3', {}, '$.title'],
|
|
72
|
+
['p', {}, '$.text'],
|
|
73
|
+
{ $if: ['$.href', ['a', { href: '$.href', class: 'btn' }, '$.link']] },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
match: "$..[?@.kind == 'code']", mode: 'ui',
|
|
78
|
+
body: ['div', { class: 'code-card' },
|
|
79
|
+
{ $if: [{ $or: ['$.title', '$.badge'] },
|
|
80
|
+
['div', { class: 'code-head' },
|
|
81
|
+
{ $if: ['$.title', ['span', { class: 'code-title' }, '$.title']] },
|
|
82
|
+
{ $if: ['$.badge', ['span', { class: 'code-badge' }, '$.badge']] },
|
|
83
|
+
]] },
|
|
84
|
+
['pre', { class: 'code-block' }, ['code', {}, '$.text']],
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
match: "$..[?@.kind == 'error']", mode: 'ui',
|
|
89
|
+
body: ['div', { class: 'error-card' },
|
|
90
|
+
['p', { class: 'error-title' }, '$.title'],
|
|
91
|
+
['p', { class: 'error-message' }, '$.message'],
|
|
92
|
+
{ $if: ['$.detail', ['p', { class: 'error-detail' }, '$.detail']] },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
match: "$..[?@.kind == 'details']", mode: 'ui',
|
|
97
|
+
body: ['details', { class: 'details-card' },
|
|
98
|
+
['summary', {}, '$.summary'],
|
|
99
|
+
[{ $apply: '$.items[*]' }],
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
// The chart card embeds a ready-made SVG vnode from
|
|
104
|
+
// @jarenjs/charts — the value splices in verbatim, no dispatch.
|
|
105
|
+
match: "$..[?@.kind == 'chart']", mode: 'ui',
|
|
106
|
+
body: ['div', { class: 'chart-card' },
|
|
107
|
+
{ $if: ['$.title', ['h3', {}, '$.title']] },
|
|
108
|
+
'$.vnode',
|
|
109
|
+
{ $if: ['$.note', ['p', { class: 'table-note' }, '$.note']] },
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
// A package-owned section: the md component has already produced the
|
|
114
|
+
// vnode, so it splices in verbatim — no dispatch, exactly like a chart.
|
|
115
|
+
match: "$..[?@.kind == 'article']", mode: 'ui',
|
|
116
|
+
body: ['div', { class: 'doc-md' }, '$.vnode'],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
match: "$..[?@.kind == 'search']", mode: 'ui',
|
|
120
|
+
body: ['input', {
|
|
121
|
+
type: 'search',
|
|
122
|
+
class: 'search-input',
|
|
123
|
+
placeholder: '$.placeholder',
|
|
124
|
+
value: '$.value',
|
|
125
|
+
on: { input: '$.action' },
|
|
126
|
+
}],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
match: "$..[?@.kind == 'more']", mode: 'ui',
|
|
130
|
+
body: ['div', { class: 'more-row' },
|
|
131
|
+
['button', { type: 'button', class: 'btn', on: { click: '$.action' } }, '$.label'],
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
];
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The data studio's boot as a closed, finite protocol. A browser
|
|
4
|
+
* store boots through five stages — the worker starting, the SQLite
|
|
5
|
+
* wasm build initializing, the OPFS pool being acquired, the topology
|
|
6
|
+
* being decided, the first store open — and every one of them finishes
|
|
7
|
+
* as a success or as a STABLE, NAMED failure: `{ code: 'DATA_BOOT',
|
|
8
|
+
* stage, message }`. An outer deadline can say only that something
|
|
9
|
+
* stopped; a named stage says which resource must be released and what
|
|
10
|
+
* a consumer can report. So each stage carries its own budget, a stage
|
|
11
|
+
* that overruns it fails under its own name, and a stage that settles
|
|
12
|
+
* late changes nothing.
|
|
13
|
+
*
|
|
14
|
+
* This module is the pure half: the stage names, the budgets, the error
|
|
15
|
+
* shape and the runner that attaches a timer to a stage and clears it
|
|
16
|
+
* on either outcome. The page's transport and the worker announce their
|
|
17
|
+
* stages through it; the browser-shaped work (a `Worker`, a
|
|
18
|
+
* `BroadcastChannel`, the wasm module) stays in the boundary and the
|
|
19
|
+
* worker, where Node cannot follow — which is why the runner takes its
|
|
20
|
+
* timers as an injection and a test drives it with millisecond budgets.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The five stages, in the order a boot passes through them. */
|
|
24
|
+
export const BOOT_STAGES = Object.freeze(['worker-start', 'sqlite-init', 'vfs-acquire', 'topology', 'store-open']);
|
|
25
|
+
|
|
26
|
+
/** The code every boot failure carries, whatever its stage. */
|
|
27
|
+
export const BOOT_ERROR_CODE = 'DATA_BOOT';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The production budgets, in milliseconds: the wasm build and the first
|
|
31
|
+
* store open are real work and get room; the pool install is bounded
|
|
32
|
+
* where an engine has been seen never to settle it; a topology decision
|
|
33
|
+
* is a lock probe or a ping window.
|
|
34
|
+
* @type {Readonly<Record<string, number>>}
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_BOOT_BUDGETS = Object.freeze({
|
|
37
|
+
'worker-start': 15_000,
|
|
38
|
+
'sqlite-init': 30_000,
|
|
39
|
+
'vfs-acquire': 8_000,
|
|
40
|
+
'topology': 5_000,
|
|
41
|
+
'store-open': 30_000,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The one failure shape a boot can end in: the stage that failed, the
|
|
46
|
+
* cause's message, and the fixed code.
|
|
47
|
+
*/
|
|
48
|
+
export class DataBootError extends Error {
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} stage - one of {@link BOOT_STAGES}
|
|
51
|
+
* @param {string} message - what happened, for a person
|
|
52
|
+
* @param {unknown} [cause]
|
|
53
|
+
*/
|
|
54
|
+
constructor(stage, message, cause = undefined) {
|
|
55
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
56
|
+
this.name = 'DataBootError';
|
|
57
|
+
/** @type {typeof BOOT_ERROR_CODE} */
|
|
58
|
+
this.code = BOOT_ERROR_CODE;
|
|
59
|
+
/** @type {string} */
|
|
60
|
+
this.stage = stage;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The page-visible record: exactly `{ code, stage, message }`. */
|
|
64
|
+
toJSON() {
|
|
65
|
+
return { code: this.code, stage: this.stage, message: this.message };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Any failure as a boot failure under `stage`: a `DataBootError` keeps
|
|
71
|
+
* the stage it already names, anything else is wrapped with its message.
|
|
72
|
+
* @param {string} stage
|
|
73
|
+
* @param {unknown} error
|
|
74
|
+
* @returns {DataBootError}
|
|
75
|
+
*/
|
|
76
|
+
export function bootFailure(stage, error) {
|
|
77
|
+
if (error instanceof DataBootError) return error;
|
|
78
|
+
const raised = /** @type {any} */ (error);
|
|
79
|
+
const message = typeof raised?.message === 'string' && raised.message.length > 0
|
|
80
|
+
? raised.message : String(error);
|
|
81
|
+
return new DataBootError(stage, message, error);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the budgets a boot runs under: the defaults, overridden per
|
|
86
|
+
* stage by a positive finite number; anything else is ignored rather than
|
|
87
|
+
* turned into a zero budget nobody meant.
|
|
88
|
+
* @param {unknown} overrides - a partial `{ stage: ms }` record, or not
|
|
89
|
+
* @returns {Readonly<Record<string, number>>}
|
|
90
|
+
*/
|
|
91
|
+
export function resolveBootBudgets(overrides) {
|
|
92
|
+
const budgets = { ...DEFAULT_BOOT_BUDGETS };
|
|
93
|
+
if (overrides !== null && typeof overrides === 'object') {
|
|
94
|
+
for (const stage of BOOT_STAGES) {
|
|
95
|
+
const value = /** @type {any} */ (overrides)[stage];
|
|
96
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) budgets[stage] = value;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return Object.freeze(budgets);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The stage runner. `run(stage, work)` starts `stage`'s timer, calls
|
|
104
|
+
* `work(advance)` and settles ONCE: with the work's value, with the
|
|
105
|
+
* work's failure named under the stage current at that moment, or —
|
|
106
|
+
* when the current stage's budget runs out first — with a
|
|
107
|
+
* `DataBootError` naming that stage. `advance(next)` moves the run to
|
|
108
|
+
* a later stage: the previous timer is cleared and the next stage's
|
|
109
|
+
* budget starts, so a multi-stage piece of work (the worker's init,
|
|
110
|
+
* which announces its stages as it passes them) is bounded per stage
|
|
111
|
+
* from the page. The timer is cleared on every outcome, and a late
|
|
112
|
+
* settlement after a timeout is dropped: the failure a consumer saw is
|
|
113
|
+
* the failure it keeps.
|
|
114
|
+
*
|
|
115
|
+
* @param {{ budgets?: Readonly<Record<string, number>>,
|
|
116
|
+
* setTimer?: (fn: () => void, ms: number) => any,
|
|
117
|
+
* clearTimer?: (handle: any) => void }} [options] - budgets per stage
|
|
118
|
+
* and the timer pair (the platform's by default)
|
|
119
|
+
*/
|
|
120
|
+
export function createStageRunner(options = {}) {
|
|
121
|
+
const budgets = options.budgets ?? DEFAULT_BOOT_BUDGETS;
|
|
122
|
+
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
123
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
124
|
+
const pending = new Set();
|
|
125
|
+
let disposed = false;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {string} stage
|
|
129
|
+
* @param {(advance: (next: string) => void) => any} work
|
|
130
|
+
* @returns {Promise<any>}
|
|
131
|
+
*/
|
|
132
|
+
const run = (stage, work) => new Promise((resolve, reject) => {
|
|
133
|
+
if (disposed) { reject(new DataBootError(stage, 'the boot runner is disposed')); return; }
|
|
134
|
+
if (!BOOT_STAGES.includes(stage)) {
|
|
135
|
+
reject(new TypeError(`a boot stage is one of ${BOOT_STAGES.join(', ')}, not '${stage}'`));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
let current = stage;
|
|
139
|
+
let settled = false;
|
|
140
|
+
/** @type {any} */
|
|
141
|
+
let timer = null;
|
|
142
|
+
const stop = () => {
|
|
143
|
+
if (timer !== null) {
|
|
144
|
+
clearTimer(timer);
|
|
145
|
+
timer = null;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const cancel = () => {
|
|
149
|
+
if (settled) return;
|
|
150
|
+
settled = true; stop(); pending.delete(cancel);
|
|
151
|
+
reject(new DataBootError(current, 'the boot was cancelled'));
|
|
152
|
+
};
|
|
153
|
+
pending.add(cancel);
|
|
154
|
+
const arm = () => {
|
|
155
|
+
stop();
|
|
156
|
+
const budget = budgets[current];
|
|
157
|
+
timer = setTimer(() => {
|
|
158
|
+
timer = null;
|
|
159
|
+
if (settled) return;
|
|
160
|
+
settled = true;
|
|
161
|
+
pending.delete(cancel);
|
|
162
|
+
reject(new DataBootError(current, `${current} did not finish within ${budget} ms`));
|
|
163
|
+
}, budget);
|
|
164
|
+
};
|
|
165
|
+
const advance = (/** @type {string} */ next) => {
|
|
166
|
+
if (settled) return;
|
|
167
|
+
if (!BOOT_STAGES.includes(next)) return;
|
|
168
|
+
current = next;
|
|
169
|
+
arm();
|
|
170
|
+
};
|
|
171
|
+
arm();
|
|
172
|
+
let out;
|
|
173
|
+
try {
|
|
174
|
+
out = work(advance);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
settled = true;
|
|
178
|
+
pending.delete(cancel);
|
|
179
|
+
stop();
|
|
180
|
+
reject(bootFailure(current, error));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
Promise.resolve(out).then(
|
|
184
|
+
(value) => {
|
|
185
|
+
if (settled) return;
|
|
186
|
+
settled = true;
|
|
187
|
+
pending.delete(cancel);
|
|
188
|
+
stop();
|
|
189
|
+
resolve(value);
|
|
190
|
+
},
|
|
191
|
+
(error) => {
|
|
192
|
+
if (settled) return;
|
|
193
|
+
settled = true;
|
|
194
|
+
pending.delete(cancel);
|
|
195
|
+
stop();
|
|
196
|
+
reject(bootFailure(current, error));
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const dispose = () => { disposed = true; for (const cancel of [...pending]) cancel(); };
|
|
201
|
+
return Object.freeze({ run, budgets, dispose });
|
|
202
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Shared owner/client worker protocol. Hosts supply the concrete initializer and storage identity. */
|
|
3
|
+
import { wasmDriver, sqlite3Handle, indexedDbSnapshotHandle, openSnapshotStorage } from '@jarenjs/db/wasm';
|
|
4
|
+
import { servePort } from '@jarenjs/contract/port';
|
|
5
|
+
import { selectBrowserStorage, discoverStorageOwner } from './storage.js';
|
|
6
|
+
import { createDataHandlers } from './handlers.js';
|
|
7
|
+
import { dataContract } from './contract.js';
|
|
8
|
+
/**
|
|
9
|
+
* The SQLite module is injected, like the handle consumed by @jarenjs/db/wasm.
|
|
10
|
+
* No package import or worker URL is chosen by this component.
|
|
11
|
+
* @param {{ initialize: () => Promise<any>, scope: any, createChannel: (name: string) => any,
|
|
12
|
+
* identity: { channel: string, pool: string, database: string, snapshots: string, lock: string }, operators?: any }} env
|
|
13
|
+
* @returns {{ dispose: () => Promise<void> }}
|
|
14
|
+
*/
|
|
15
|
+
export function createBrowserDataWorker(env) {
|
|
16
|
+
const { channel: CHANNEL, pool: POOL, database: DB_NAME, snapshots: SNAPSHOTS, lock: OWNER_LOCK } = env.identity;
|
|
17
|
+
if ([CHANNEL, POOL, DB_NAME, SNAPSHOTS, OWNER_LOCK].some(value => typeof value !== 'string' || value.length === 0))
|
|
18
|
+
throw new TypeError('Data storage requires explicit channel, pool, database, snapshots and lock identities.');
|
|
19
|
+
const OPERATORS = env.operators;
|
|
20
|
+
const context = {
|
|
21
|
+
/** @type {any} */ sqlite3: null,
|
|
22
|
+
/** @type {any} */ poolUtil: null,
|
|
23
|
+
vfs: 'memory',
|
|
24
|
+
isOwner: false,
|
|
25
|
+
locked: false,
|
|
26
|
+
releaseOwner: null,
|
|
27
|
+
handle: null,
|
|
28
|
+
unlink: null,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const channel = env.createChannel(CHANNEL);
|
|
32
|
+
let disposed = false, peerServer = null, initial = null;
|
|
33
|
+
const pings = new Set();
|
|
34
|
+
const current = () => { if (disposed) throw new Error('The Data worker is disposed.'); };
|
|
35
|
+
|
|
36
|
+
/** Announce the boot stage this worker is entering (`lib/boot-stages.js`):
|
|
37
|
+
* the page bounds each stage from its side and names it on a timeout, so
|
|
38
|
+
* an install some engines never settle fails as `vfs-acquire` — never as
|
|
39
|
+
* an OPFS absence, which is a different answer with a different remedy. */
|
|
40
|
+
const enter = stage => { current(); env.scope.postMessage({ boot: stage }); };
|
|
41
|
+
|
|
42
|
+
/** Ask the channel whether an OPFS owner already exists — the fallback
|
|
43
|
+
* for a host with no `LockManager`. A running owner answers its pong;
|
|
44
|
+
* silence means only that no owner answered in this window. Without Web
|
|
45
|
+
* Locks the storage-acquisition refusal and a longer retry decide the
|
|
46
|
+
* next step; a busy owner cannot answer while SQLite runs synchronously. */
|
|
47
|
+
function pingForOwner(ms = 600) {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
const token = `ping-${env.scope.crypto.randomUUID()}`;
|
|
50
|
+
const onPong = (event) => {
|
|
51
|
+
if (event.data?.pong === token) {
|
|
52
|
+
finish({ vfs: event.data.vfs });
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
let timer;
|
|
56
|
+
const finish = value => { clearTimeout(timer); channel.removeEventListener('message', onPong); pings.delete(stop); resolve(value); };
|
|
57
|
+
const stop = () => finish(false); pings.add(stop);
|
|
58
|
+
channel.addEventListener('message', onPong);
|
|
59
|
+
timer = setTimeout(stop, ms);
|
|
60
|
+
channel.postMessage({ ping: token });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The host's lock manager, where it has one. */
|
|
65
|
+
const locks = () => env.scope.navigator?.locks;
|
|
66
|
+
|
|
67
|
+
/** Acquire the browser-owned lock before selecting any durable storage. */
|
|
68
|
+
function holdOwnerLock() {
|
|
69
|
+
const manager = locks();
|
|
70
|
+
if (manager === undefined) return Promise.resolve(true);
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
manager.request(OWNER_LOCK, { ifAvailable: true }, (lock) => {
|
|
73
|
+
if (lock === null) { resolve(false); return; }
|
|
74
|
+
if (disposed) { resolve(false); return; }
|
|
75
|
+
context.locked = true;
|
|
76
|
+
resolve(true);
|
|
77
|
+
return new Promise((release) => { context.releaseOwner = release; });
|
|
78
|
+
}).catch(reject);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A disposable write/close/reopen proves the selected VFS, not its name. */
|
|
83
|
+
async function probeVfs(DbClass, vfs, unlink) {
|
|
84
|
+
const path = `${DB_NAME}.probe`;
|
|
85
|
+
let db;
|
|
86
|
+
try {
|
|
87
|
+
db = new DbClass(path, 'c');
|
|
88
|
+
if (!context.sqlite3.capi.sqlite3_js_db_uses_vfs(db.pointer, vfs))
|
|
89
|
+
throw new Error(`the opened database did not use ${vfs}`);
|
|
90
|
+
db.exec('CREATE TABLE IF NOT EXISTS probe(n); DELETE FROM probe; INSERT INTO probe VALUES(73)');
|
|
91
|
+
db.close();
|
|
92
|
+
db = new DbClass(path, 'r');
|
|
93
|
+
if (db.selectValue('SELECT n FROM probe') !== 73) throw new Error('VFS reopen did not preserve the probe');
|
|
94
|
+
}
|
|
95
|
+
finally { db?.close(); await unlink(path); }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function initialize() {
|
|
99
|
+
current();
|
|
100
|
+
enter('sqlite-init');
|
|
101
|
+
context.sqlite3 = await env.initialize();
|
|
102
|
+
current();
|
|
103
|
+
enter('vfs-acquire');
|
|
104
|
+
if (!(await holdOwnerLock())) {
|
|
105
|
+
enter('topology');
|
|
106
|
+
const owner = await pingForOwner();
|
|
107
|
+
return { topology: 'client', vfs: owner?.vfs ?? 'owner-selected',
|
|
108
|
+
version: context.sqlite3.version.libVersion,
|
|
109
|
+
refusal: { code: 'JD2061', message: 'another context owns the database; this tab uses its connection over a BroadcastChannel' } };
|
|
110
|
+
}
|
|
111
|
+
current();
|
|
112
|
+
const sqlite3 = context.sqlite3;
|
|
113
|
+
const selected = await selectBrowserStorage({
|
|
114
|
+
isolated: env.scope.crossOriginIsolated === true,
|
|
115
|
+
sharedArrayBuffer: typeof env.scope.SharedArrayBuffer === 'function',
|
|
116
|
+
sab: async () => {
|
|
117
|
+
if (!sqlite3.capi.sqlite3_vfs_find('opfs') || typeof sqlite3.oo1.OpfsDb !== 'function')
|
|
118
|
+
throw new Error('the SharedArrayBuffer OPFS VFS is not registered');
|
|
119
|
+
const root = await env.scope.navigator.storage.getDirectory();
|
|
120
|
+
// The pinned build removes its private sqlite3.opfs helper after
|
|
121
|
+
// initialization. Delete closed files through the public storage API.
|
|
122
|
+
const unlink = async (path) => {
|
|
123
|
+
for (const suffix of ['', '-journal', '-wal', '-shm']) {
|
|
124
|
+
try { await root.removeEntry(path.replace(/^\//, '') + suffix); }
|
|
125
|
+
catch (error) { if (error.name !== 'NotFoundError') throw error; }
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
await probeVfs(sqlite3.oo1.OpfsDb, 'opfs', unlink);
|
|
129
|
+
return { handle: sqlite3Handle(sqlite3, { DbClass: sqlite3.oo1.OpfsDb }), unlink };
|
|
130
|
+
},
|
|
131
|
+
sah: async () => {
|
|
132
|
+
context.poolUtil = await sqlite3.installOpfsSAHPoolVfs({ name: POOL });
|
|
133
|
+
if (disposed) context.poolUtil.pauseVfs?.();
|
|
134
|
+
current();
|
|
135
|
+
const unlink = (path) => context.poolUtil.unlink(path);
|
|
136
|
+
await probeVfs(context.poolUtil.OpfsSAHPoolDb, context.poolUtil.vfsName, unlink);
|
|
137
|
+
return { handle: sqlite3Handle(sqlite3, { DbClass: context.poolUtil.OpfsSAHPoolDb }), unlink };
|
|
138
|
+
},
|
|
139
|
+
indexedDB: async () => {
|
|
140
|
+
if (!context.locked) throw new Error('IndexedDB snapshots require an observed exclusive Web Lock');
|
|
141
|
+
const handle = indexedDbSnapshotHandle(sqlite3, { name: SNAPSHOTS });
|
|
142
|
+
const driver = wasmDriver(handle);
|
|
143
|
+
const path = `${DB_NAME}.probe`;
|
|
144
|
+
let db;
|
|
145
|
+
const unlink = async (key) => {
|
|
146
|
+
const storage = await openSnapshotStorage(env.scope.indexedDB, SNAPSHOTS);
|
|
147
|
+
try { await storage.remove(key); } finally { storage.close(); }
|
|
148
|
+
};
|
|
149
|
+
try {
|
|
150
|
+
db = await driver.open(path);
|
|
151
|
+
await db.exec('CREATE TABLE IF NOT EXISTS probe(n); DELETE FROM probe; INSERT INTO probe VALUES(73)');
|
|
152
|
+
await db.close();
|
|
153
|
+
db = await driver.open(path);
|
|
154
|
+
if ((await (await db.prepare('SELECT n FROM probe')).get()).n !== 73)
|
|
155
|
+
throw new Error('IndexedDB reopen did not preserve the probe');
|
|
156
|
+
}
|
|
157
|
+
finally { await db?.close(); await unlink(path); }
|
|
158
|
+
return { handle, unlink };
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
current();
|
|
162
|
+
enter('topology');
|
|
163
|
+
if (selected.held || (!selected.durable && !context.locked)) {
|
|
164
|
+
const owner = await discoverStorageOwner(selected, pingForOwner);
|
|
165
|
+
if (owner) {
|
|
166
|
+
context.releaseOwner?.();
|
|
167
|
+
context.locked = false;
|
|
168
|
+
return { topology: 'client', vfs: owner.vfs, version: sqlite3.version.libVersion,
|
|
169
|
+
refusal: { code: 'JD2061', message: 'another context answered as the database owner; this tab uses its connection over a BroadcastChannel' } };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
context.vfs = selected.vfs;
|
|
173
|
+
context.handle = selected.handle ?? sqlite3Handle(sqlite3);
|
|
174
|
+
context.unlink = selected.unlink ?? (() => {});
|
|
175
|
+
context.isOwner = selected.durable;
|
|
176
|
+
if (context.isOwner) serveChannel();
|
|
177
|
+
else { context.releaseOwner?.(); context.locked = false; }
|
|
178
|
+
const fallback = selected.failures.map((entry) => `${entry.vfs}: ${entry.reason}`).join('; ');
|
|
179
|
+
return { topology: selected.durable ? 'owner' : 'memory', vfs: selected.vfs,
|
|
180
|
+
durable: selected.durable, failures: selected.failures, version: sqlite3.version.libVersion,
|
|
181
|
+
...(fallback ? { refusal: { code: 'JD2061', message: `${fallback}. Selected ${selected.vfs}`
|
|
182
|
+
+ (selected.durable ? ' (persistent).' : ' (non-durable; data lasts until reload).') } } : {}) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Every reopen drops the live registrations of every tab, so every tab
|
|
186
|
+
* is told. The notice travels beside the contract frames on both
|
|
187
|
+
* transports and is distinguishable by shape, exactly as the
|
|
188
|
+
* owner-discovery frames are. */
|
|
189
|
+
function announce(notice) {
|
|
190
|
+
const message = { ...notice, channel: CHANNEL };
|
|
191
|
+
channel.postMessage(message);
|
|
192
|
+
env.scope.postMessage(message);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const table = createDataHandlers({
|
|
196
|
+
init: () => initial ??= initialize(),
|
|
197
|
+
makeDriver: () => wasmDriver(context.handle),
|
|
198
|
+
// the oracle's throwaway store is always a plain in-memory database,
|
|
199
|
+
// whichever VFS this context settled on: the pool's class is for the
|
|
200
|
+
// one database it holds, and the corpus needs execution, not persistence
|
|
201
|
+
makeScratchDriver: () => wasmDriver(sqlite3Handle(context.sqlite3)),
|
|
202
|
+
path: () => (context.vfs === 'memory' ? ':memory:' : DB_NAME),
|
|
203
|
+
vfs: () => context.vfs,
|
|
204
|
+
durable: () => context.vfs !== 'memory',
|
|
205
|
+
unlink: () => context.unlink(DB_NAME),
|
|
206
|
+
announce,
|
|
207
|
+
operators: OPERATORS,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// the owning tab's own requests arrive on the worker channel
|
|
211
|
+
const directServer = servePort(dataContract, table.handlers, { channel: env.scope });
|
|
212
|
+
|
|
213
|
+
let channelServed = false;
|
|
214
|
+
/** Client tabs reach the owner here — registered exactly once, on
|
|
215
|
+
* becoming the owner; their subscriptions push back on this channel. */
|
|
216
|
+
function serveChannel() {
|
|
217
|
+
if (channelServed) return;
|
|
218
|
+
channelServed = true;
|
|
219
|
+
peerServer = servePort(dataContract, table.clientHandlers, { channel });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// answer an owner-discovery ping (§11) only while actually owning; the
|
|
223
|
+
// contract frames on this channel belong to servePort's own listener
|
|
224
|
+
const onMessage = (event) => {
|
|
225
|
+
const message = event.data;
|
|
226
|
+
if (message === null || typeof message !== 'object') return;
|
|
227
|
+
if (message.ping !== undefined && context.isOwner) {
|
|
228
|
+
channel.postMessage({ pong: message.ping, vfs: context.vfs });
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
channel.addEventListener('message', onMessage);
|
|
233
|
+
env.scope.postMessage({ ready: true });
|
|
234
|
+
let disposal = null;
|
|
235
|
+
return { dispose() {
|
|
236
|
+
if (disposal) return disposal;
|
|
237
|
+
disposed = true; context.isOwner = false;
|
|
238
|
+
for (const stop of [...pings]) stop();
|
|
239
|
+
directServer.close(); peerServer?.close(); channel.removeEventListener('message', onMessage); channel.close();
|
|
240
|
+
disposal = table.dispose().finally(() => {
|
|
241
|
+
try { context.poolUtil?.pauseVfs?.(); }
|
|
242
|
+
finally { context.releaseOwner?.(); context.locked = false; }
|
|
243
|
+
});
|
|
244
|
+
return disposal;
|
|
245
|
+
} };
|
|
246
|
+
|
|
247
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
import { compileContract } from '@jarenjs/contract';
|
|
3
|
+
import document from '@jarenjs/studio/contracts/data.contract.json' with { type: 'json' };
|
|
4
|
+
/** @type {ReturnType<typeof compileContract>} */
|
|
5
|
+
export const dataContract = compileContract(document);
|
|
6
|
+
/** @type {{ $contract: string, id: string, operations: Record<string, object> } & Record<string, unknown>} */
|
|
7
|
+
export const dataContractDocument = document;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** @typedef {{ document: { model: any, query: any }, buffers: { modelText: string, queryText: string }, revision: string, result: any, explain: any }} DataSnapshot */
|
|
3
|
+
/** @typedef {{ ok: boolean, document?: any, revision?: string, conflict?: boolean, error?: string,
|
|
4
|
+
* errors?: any[], valid?: boolean, total?: number, code?: string, result?: any, explain?: any, model?: any }} EditorReceipt */
|
|
5
|
+
/** Data document publication and execution use the same queued actions as the UI. */
|
|
6
|
+
import { semanticKey } from '@jarenjs/core/object';
|
|
7
|
+
import { applyJSONPatch } from '@jarenjs/json/patch';
|
|
8
|
+
import { validateFile } from '../validate.js';
|
|
9
|
+
|
|
10
|
+
/** @param {{ getApp: () => any, runtime: any, operators?: { toOptions: () => any } }} env */
|
|
11
|
+
export function createDataController(env) {
|
|
12
|
+
let disposed = false, detach = null, sequence = 0;
|
|
13
|
+
const pending = new Map(), listeners = new Set();
|
|
14
|
+
const parse = text => { try { return JSON.parse(text); } catch { return null; } };
|
|
15
|
+
/** @returns {DataSnapshot} */
|
|
16
|
+
function read() {
|
|
17
|
+
const data = env.getApp().getState().data;
|
|
18
|
+
const buffers = { modelText: data.modelText, queryText: data.queryText };
|
|
19
|
+
return { document: { model: parse(data.modelText), query: parse(data.queryText) }, buffers,
|
|
20
|
+
revision: semanticKey(buffers), result: structuredClone(data.results), explain: structuredClone(data.explain) };
|
|
21
|
+
}
|
|
22
|
+
/** @param {{ model: any, query: any }} candidate */
|
|
23
|
+
function validate(candidate) {
|
|
24
|
+
try {
|
|
25
|
+
if (candidate === null || typeof candidate !== 'object' || !Object.hasOwn(candidate, 'model') || !Object.hasOwn(candidate, 'query'))
|
|
26
|
+
throw new TypeError('A Data document contains model and query members.');
|
|
27
|
+
const errors = ['model', 'query'].flatMap(kind => validateFile({ kind, text: JSON.stringify(candidate[kind]) }, { operators: env.operators })
|
|
28
|
+
.errors.map(error => ({ ...error, member: kind })));
|
|
29
|
+
return { valid: errors.length === 0, total: errors.length, errors };
|
|
30
|
+
}
|
|
31
|
+
catch (error) { return { valid: false, total: 1, errors: [{ code: error.code ?? null, message: error.message }] }; }
|
|
32
|
+
}
|
|
33
|
+
function settle(id, outcome) { const resolve = pending.get(id); pending.delete(id); resolve?.({ ...read(), ...outcome }); }
|
|
34
|
+
/** @param {{ model: any, query: any }} candidate @param {{ expectedRevision?: string }} [options]
|
|
35
|
+
* @returns {Promise<EditorReceipt>}
|
|
36
|
+
*/
|
|
37
|
+
function replace(candidate, options = {}) {
|
|
38
|
+
if (disposed) return Promise.resolve({ ok: false, error: 'The Data editor is disposed.' });
|
|
39
|
+
const before = read();
|
|
40
|
+
if (before.revision !== options.expectedRevision) return Promise.resolve({ ok: false, conflict: true, ...before });
|
|
41
|
+
const checked = validate(candidate);
|
|
42
|
+
if (!checked.valid) return Promise.resolve({ ok: false, ...checked });
|
|
43
|
+
return new Promise(resolve => {
|
|
44
|
+
const id = ++sequence; pending.set(id, resolve);
|
|
45
|
+
env.getApp().dispatch('data/replace', { requestId: id, expected: before.buffers,
|
|
46
|
+
modelText: JSON.stringify(candidate.model, null, 2), queryText: JSON.stringify(candidate.query, null, 2) });
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** @param {Parameters<typeof applyJSONPatch>[1]} patch @param {{ expectedRevision?: string }} [options]
|
|
50
|
+
* @returns {Promise<EditorReceipt>}
|
|
51
|
+
*/
|
|
52
|
+
function apply(patch, options) {
|
|
53
|
+
try { return replace(applyJSONPatch(read().document, patch), options); }
|
|
54
|
+
catch (error) { return Promise.resolve({ ok: false, error: error.message, code: error.code }); }
|
|
55
|
+
}
|
|
56
|
+
/** Query the open store, or explicitly recreate it through the owning host.
|
|
57
|
+
* @param {{ operation?: 'query'|'open', externals?: Record<string, any> }} [options]
|
|
58
|
+
* @returns {Promise<EditorReceipt>}
|
|
59
|
+
*/
|
|
60
|
+
function run(options = {}) {
|
|
61
|
+
if (disposed) return Promise.resolve({ ok: false, error: 'The Data editor is disposed.' });
|
|
62
|
+
const operation = options.operation ?? 'query';
|
|
63
|
+
if (!['query', 'open'].includes(operation)) return Promise.resolve({ ok: false, error: 'The Data operation must be query or open.' });
|
|
64
|
+
return new Promise(resolve => {
|
|
65
|
+
const id = ++sequence; pending.set(id, resolve);
|
|
66
|
+
env.getApp().dispatch('data/execute', { requestId: id, operation, externals: options.externals ?? {} });
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** @param {(snapshot: DataSnapshot) => void} listener */
|
|
70
|
+
function subscribe(listener) { if (disposed) return () => {}; listeners.add(listener); return () => listeners.delete(listener); }
|
|
71
|
+
function attach() {
|
|
72
|
+
if (disposed || detach) return;
|
|
73
|
+
let revision = read().revision;
|
|
74
|
+
detach = env.getApp().subscribe(() => {
|
|
75
|
+
const value = read();
|
|
76
|
+
if (revision !== value.revision) { revision = value.revision; for (const listener of listeners) listener(value); }
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function dispose() {
|
|
80
|
+
if (disposed) return;
|
|
81
|
+
disposed = true; detach?.(); detach = null; listeners.clear(); env.runtime.dispose();
|
|
82
|
+
for (const resolve of pending.values()) resolve({ ok: false, error: 'The Data editor is disposed.' });
|
|
83
|
+
pending.clear();
|
|
84
|
+
}
|
|
85
|
+
const effects = {
|
|
86
|
+
'data-accepted': id => settle(id, { ok: true }),
|
|
87
|
+
'data-refused': id => settle(id, { ok: false, conflict: true }),
|
|
88
|
+
'data-editor-run': Object.assign(async (props, dispatch) => {
|
|
89
|
+
if (disposed) { settle(props.requestId, { ok: false, error: 'The Data editor is disposed.' }); return; }
|
|
90
|
+
const result = await env.runtime.effects[props.operation === 'open' ? 'data-open' : 'data-run'](props, dispatch);
|
|
91
|
+
settle(props.requestId, result);
|
|
92
|
+
}, { dispose }),
|
|
93
|
+
};
|
|
94
|
+
return { read, validate, replace, apply, run, subscribe, attach, effects, dispose };
|
|
95
|
+
}
|