@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.
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
@@ -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
+ }