@jarenjs/studio 0.83.2 → 0.84.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -6
- package/contracts/data.contract.json +243 -0
- package/dist/types/component/data/actions.d.ts +234 -0
- package/dist/types/component/data/index.d.ts +9 -0
- package/dist/types/component/data/mount.d.ts +87 -0
- package/dist/types/component/data/project-widget.d.ts +27 -0
- package/dist/types/component/data/viewmodel.d.ts +85 -0
- package/dist/types/component/data/views.d.ts +148 -0
- package/dist/types/component/document.d.ts +82 -0
- package/dist/types/component/flow/actions.d.ts +708 -0
- package/dist/types/component/flow/index.d.ts +7 -0
- package/dist/types/component/flow/mount.d.ts +66 -0
- package/dist/types/component/flow/project-widget.d.ts +8 -0
- package/dist/types/component/flow/runtime.d.ts +57 -0
- package/dist/types/component/flow/views.d.ts +242 -0
- package/dist/types/component/host.d.ts +2 -2
- package/dist/types/component/index.d.ts +21 -15
- package/dist/types/component/mount.d.ts +51 -0
- package/dist/types/component/project-actions.d.ts +302 -0
- package/dist/types/component/project-controller.d.ts +81 -0
- package/dist/types/component/project-state.d.ts +1 -0
- package/dist/types/component/project.d.ts +327 -0
- package/dist/types/component/shared/host-widget.d.ts +24 -0
- package/dist/types/component/shared/memo.d.ts +12 -0
- package/dist/types/component/shared/nodes.d.ts +92 -0
- package/dist/types/component/shared/schema-options.d.ts +28 -0
- package/dist/types/component/shared/studio-kit.d.ts +78 -0
- package/dist/types/component/shared/ui.d.ts +136 -0
- package/dist/types/component/view.d.ts +6 -6
- package/dist/types/data/boot-stages.d.ts +97 -0
- package/dist/types/data/browser-worker.d.ts +22 -0
- package/dist/types/data/contract.d.ts +9 -0
- package/dist/types/data/editor.d.ts +81 -0
- package/dist/types/data/handlers.d.ts +130 -0
- package/dist/types/data/host.d.ts +8 -0
- package/dist/types/data/project-worker.d.ts +8 -0
- package/dist/types/data/runtime.d.ts +46 -0
- package/dist/types/data/state.d.ts +33 -0
- package/dist/types/data/storage.d.ts +26 -0
- package/dist/types/data/transport.d.ts +69 -0
- package/dist/types/flow-document.d.ts +19 -0
- package/dist/types/flow-editor.d.ts +88 -0
- package/docs/EDITORS.md +158 -0
- package/docs/PROJECT-FORMAT.md +1 -11
- package/package.json +32 -15
- package/src/component/data/actions.js +138 -0
- package/src/component/data/index.js +10 -0
- package/src/component/data/mount.js +63 -0
- package/src/component/data/project-widget.js +164 -0
- package/src/component/data/viewmodel.js +185 -0
- package/src/component/data/views.js +241 -0
- package/src/component/document.js +344 -0
- package/src/component/flow/actions.js +331 -0
- package/src/component/flow/index.js +8 -0
- package/src/component/flow/mount.js +53 -0
- package/src/component/flow/project-widget.js +44 -0
- package/src/component/flow/runtime.js +481 -0
- package/src/component/flow/views.js +196 -0
- package/src/component/host.js +2 -2
- package/src/component/index.js +19 -9
- package/src/component/mount.js +43 -0
- package/src/component/project-actions.js +189 -0
- package/src/component/project-controller.js +248 -0
- package/src/component/project-state.js +30 -0
- package/src/component/project.js +308 -0
- package/src/component/shared/host-widget.js +35 -0
- package/src/component/shared/memo.js +28 -0
- package/src/component/shared/nodes.js +94 -0
- package/src/component/shared/schema-options.js +30 -0
- package/src/component/shared/studio-kit.js +59 -0
- package/src/component/shared/ui.js +134 -0
- package/src/data/boot-stages.js +202 -0
- package/src/data/browser-worker.js +247 -0
- package/src/data/contract.js +7 -0
- package/src/data/editor.js +95 -0
- package/src/data/handlers.js +349 -0
- package/src/data/host.js +8 -0
- package/src/data/project-worker.js +26 -0
- package/src/data/runtime.js +462 -0
- package/src/data/state.js +46 -0
- package/src/data/storage.js +61 -0
- package/src/data/transport.js +215 -0
- package/src/flow-document.js +24 -0
- package/src/flow-editor.js +98 -0
- package/styles/data.css +53 -0
- package/styles/editor.css +150 -0
- package/styles/flow.css +99 -0
- package/styles/studio.css +1 -0
- package/dist/types/author.d.ts +0 -27
- package/src/author.js +0 -55
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Data operations and store lifecycle, independent of any route or SQLite initializer. */
|
|
3
|
+
import { equalsJson } from '@jarenjs/core/object';
|
|
4
|
+
import { applyJSONPatch } from '@jarenjs/json/patch';
|
|
5
|
+
import { createTransport } from './transport.js';
|
|
6
|
+
import { bootFailure } from './boot-stages.js';
|
|
7
|
+
/** The live query the right pane maintains. */
|
|
8
|
+
const LIVE_QUERY = [{ $for: { it: '$[*]' }, $return: '$it' }];
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The collection an opened model puts the studio on: the first one it
|
|
13
|
+
* declares. A model with none is a refusal the store itself raises, so
|
|
14
|
+
* this only has to answer honestly for a model that has one.
|
|
15
|
+
* @param {any} model
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
const firstCollection = (model) => Object.keys(model?.collections ?? {})[0] ?? '';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The key pointer a model declares for one of its collections — how a
|
|
22
|
+
* row list names the document a delete is about. Models declare it; the
|
|
23
|
+
* store's own default is `/id`.
|
|
24
|
+
* @param {any} model
|
|
25
|
+
* @param {string} name
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
const keyPointerOf = (model, name) => model?.collections?.[name]?.key ?? '/id';
|
|
29
|
+
|
|
30
|
+
/** The executor this tab is, as a disagreement names it. */
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether one oracle answer is the one the engine recorded: the empty
|
|
35
|
+
* sequence crosses as a flag (null is an answer), everything else as
|
|
36
|
+
* JSON, compared structurally.
|
|
37
|
+
* @param {any} entry - a corpus entry, as the site ships it
|
|
38
|
+
* @param {{ answer: any, empty: boolean }} outcome
|
|
39
|
+
*/
|
|
40
|
+
const agrees = (entry, outcome) => (entry.empty === true
|
|
41
|
+
? outcome.empty === true
|
|
42
|
+
: outcome.empty === false && equalsJson(outcome.answer, entry.expected));
|
|
43
|
+
|
|
44
|
+
/** What the engine recorded, spelled for a report line. */
|
|
45
|
+
const recorded = (/** @type {any} */ entry) => (entry.empty === true
|
|
46
|
+
? 'the empty sequence' : JSON.stringify(entry.expected));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The runtime: effects plus the exported view model.
|
|
50
|
+
* @param {any} env - explicit seed documents, transport, lifecycle and optional worked examples.
|
|
51
|
+
*/
|
|
52
|
+
export function createDataRuntime(env = {}) {
|
|
53
|
+
const EXECUTOR = env.executor ?? 'sqlite';
|
|
54
|
+
const buildTransport = env.transport ?? (() => createTransport(env.transportOptions));
|
|
55
|
+
const DATA_MODEL = structuredClone(env.model), DATA_QUERY = structuredClone(env.query),
|
|
56
|
+
SEEDS = structuredClone(env.seeds ?? []), TRIP_CSV = env.trip?.csv ?? '';
|
|
57
|
+
/** @type {ReturnType<typeof createTransport> | null} */
|
|
58
|
+
let transport = null;
|
|
59
|
+
/** @type {{ stop: () => void } | null} */
|
|
60
|
+
let liveSub = null;
|
|
61
|
+
/** @type {any} */
|
|
62
|
+
let liveDoc = null;
|
|
63
|
+
let liveAvailable = true;
|
|
64
|
+
let topology = 'boot', disposed = false, generation = 0;
|
|
65
|
+
const lifecycleTarget = env.lifecycleTarget ?? globalThis;
|
|
66
|
+
const releaseLive = () => liveSub?.stop();
|
|
67
|
+
let activeModel = DATA_MODEL;
|
|
68
|
+
/** The collection every effect works on: the model pane is EDITABLE,
|
|
69
|
+
* so naming one literally makes editing the model produce a studio
|
|
70
|
+
* that queries a collection the model no longer declares. It is the
|
|
71
|
+
* first collection the open model declares, and it moves with it. */
|
|
72
|
+
let collection = firstCollection(DATA_MODEL);
|
|
73
|
+
|
|
74
|
+
/** A reopen notice and an attach response describe the same store. */
|
|
75
|
+
const acceptStore = (opened = {}, dispatch) => {
|
|
76
|
+
activeModel = opened.model ?? activeModel;
|
|
77
|
+
collection = opened.collection ?? firstCollection(activeModel);
|
|
78
|
+
liveAvailable = opened?.capabilities?.live !== false;
|
|
79
|
+
dispatch('data/opened', {
|
|
80
|
+
...opened, collection, keyPointer: opened.keyPointer ?? keyPointerOf(activeModel, collection),
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const parse = (text, what) => {
|
|
85
|
+
try {
|
|
86
|
+
return { ok: true, value: JSON.parse(text) };
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
return { ok: false, message: `${what}: invalid JSON — ${/** @type {any} */ (error).message}` };
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* (Re)subscribe the live pane over the stream binding: the snapshot
|
|
95
|
+
* replaces the maintained document, each `{ patch, seq }` emission
|
|
96
|
+
* applies copy-on-write, and after every live event the owner's
|
|
97
|
+
* registration count is refreshed (the status surface). It follows the
|
|
98
|
+
* ACTIVE collection, so a reopened model takes the pane with it.
|
|
99
|
+
* @param {(name: string, payload?: any) => void} dispatch
|
|
100
|
+
*/
|
|
101
|
+
const subscribeLive = (dispatch) => {
|
|
102
|
+
liveSub?.stop();
|
|
103
|
+
if (!liveAvailable) { liveSub = null; return; }
|
|
104
|
+
const refreshRegistrations = () => {
|
|
105
|
+
transport?.request('data.lives', null)
|
|
106
|
+
.then((lives) => dispatch('data/lives', { count: lives.count }))
|
|
107
|
+
.catch(() => {});
|
|
108
|
+
};
|
|
109
|
+
liveSub = /** @type {NonNullable<typeof transport>} */ (transport).subscribe(
|
|
110
|
+
{ collection, document: LIVE_QUERY },
|
|
111
|
+
{
|
|
112
|
+
onSnapshot: (/** @type {any} */ value) => {
|
|
113
|
+
liveDoc = value;
|
|
114
|
+
dispatch('data/live', { rows: value.rows });
|
|
115
|
+
refreshRegistrations();
|
|
116
|
+
},
|
|
117
|
+
onPatch: (/** @type {{ patch: any[], seq: number }} */ emission) => {
|
|
118
|
+
liveDoc = applyJSONPatch(liveDoc, emission.patch);
|
|
119
|
+
dispatch('data/live-event', { rows: liveDoc.rows, seq: emission.seq });
|
|
120
|
+
refreshRegistrations();
|
|
121
|
+
},
|
|
122
|
+
onError: (/** @type {any} */ outcome) =>
|
|
123
|
+
dispatch('data/error', { message: outcome.error.details?.message ?? outcome.error.message }),
|
|
124
|
+
onEnd: (/** @type {{ reason: string }} */ info) =>
|
|
125
|
+
dispatch('data/error', { message: `live stream ended (${info.reason})` }),
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Whether a store is there to talk to; an effect that needs one says
|
|
130
|
+
* so instead of doing nothing (or throwing on an undefined answer).
|
|
131
|
+
* @param {(name: string, payload?: any) => void} dispatch */
|
|
132
|
+
const withStore = (dispatch) => {
|
|
133
|
+
if (transport !== null) return true;
|
|
134
|
+
dispatch('data/error', { message: 'the store is not booted — retry the boot first' });
|
|
135
|
+
return false;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** Destructive model changes belong to the tab holding the database. */
|
|
139
|
+
const withOwner = (dispatch) => {
|
|
140
|
+
if (!withStore(dispatch)) return false;
|
|
141
|
+
if (topology === 'owner') return true;
|
|
142
|
+
dispatch('data/error', { message: 'Only the owning tab can recreate or migrate the store.' });
|
|
143
|
+
return false;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Re-read the collection into the store pane.
|
|
147
|
+
* @param {(name: string, payload?: any) => void} dispatch */
|
|
148
|
+
const refreshRows = (dispatch) => transport?.request('data.rows', { collection })
|
|
149
|
+
.then((rows) => dispatch('data/rows', { rows }))
|
|
150
|
+
.catch((error) => dispatch('data/error', { message: String(error.message ?? error) }));
|
|
151
|
+
|
|
152
|
+
// A departing tab releases its subscription so the owner's
|
|
153
|
+
// registration is not leaked (the wire's unsubscribe frame). BOTH
|
|
154
|
+
// teardown events are listened for because the engines disagree about
|
|
155
|
+
// which one a closing tab gets: Firefox delivers `beforeunload` and no
|
|
156
|
+
// `pagehide` at all, so a `pagehide`-only release leaks the owner's
|
|
157
|
+
// registration forever there — the count never comes back down and
|
|
158
|
+
// the store keeps feeding a subscription nobody reads. `stop()` is
|
|
159
|
+
// idempotent on both sides (the client marks the stream stopped, the
|
|
160
|
+
// owner's wrapper releases once), so being told twice costs
|
|
161
|
+
// nothing. The listener never calls `preventDefault`, so it cannot
|
|
162
|
+
// raise the browser's "leave site?" prompt.
|
|
163
|
+
lifecycleTarget.addEventListener?.('pagehide', releaseLive);
|
|
164
|
+
lifecycleTarget.addEventListener?.('beforeunload', releaseLive);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The boot, as the closed protocol: every attempt ends in `ready` or in
|
|
168
|
+
* the named `error` state, and a failed attempt tears its transport down
|
|
169
|
+
* before the page hears of it, so a retry (or a reload) starts clean and
|
|
170
|
+
* no worker, client, channel, listener or subscription outlives the
|
|
171
|
+
* failure. A previous transport — a retry after an error — is closed
|
|
172
|
+
* first, so exactly one transport ever exists.
|
|
173
|
+
* @param {(name: string, payload?: any) => void} dispatch
|
|
174
|
+
*/
|
|
175
|
+
const bootStore = async (dispatch, bootModel = DATA_MODEL) => {
|
|
176
|
+
if (disposed) return;
|
|
177
|
+
liveSub?.stop();
|
|
178
|
+
liveSub = null;
|
|
179
|
+
transport?.close();
|
|
180
|
+
// the boot opens the SEED model: whatever a reopen moved the studio
|
|
181
|
+
// onto, this attempt works on the seed model's first collection
|
|
182
|
+
collection = firstCollection(bootModel);
|
|
183
|
+
activeModel = bootModel;
|
|
184
|
+
topology = 'boot';
|
|
185
|
+
const booting = buildTransport();
|
|
186
|
+
transport = booting;
|
|
187
|
+
/** A later boot took over: this attempt is over and says nothing. */
|
|
188
|
+
const superseded = () => transport !== booting;
|
|
189
|
+
try {
|
|
190
|
+
const status = await booting.boot();
|
|
191
|
+
if (superseded()) return;
|
|
192
|
+
topology = status.topology;
|
|
193
|
+
liveAvailable = status.vfs !== 'indexeddb-snapshot';
|
|
194
|
+
dispatch('data/status', status);
|
|
195
|
+
// an owner (OPFS) and a standalone memory tab each hold their
|
|
196
|
+
// OWN connection, so both open and seed; only a CLIENT attaches
|
|
197
|
+
// to the connection the owner already holds (LIVE-FORMAT §11)
|
|
198
|
+
if (status.topology !== 'client') {
|
|
199
|
+
const opened = await booting.bounded('store-open',
|
|
200
|
+
() => booting.request('data.open', { model: bootModel }));
|
|
201
|
+
if (superseded()) return;
|
|
202
|
+
acceptStore(opened, dispatch);
|
|
203
|
+
for (const seedDoc of SEEDS) {
|
|
204
|
+
await booting.request('data.insert',
|
|
205
|
+
{ collection, doc: seedDoc }).catch(() => {});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
// On the channel, open without reset only reads the owner's
|
|
210
|
+
// current model and capabilities; it never reopens the store.
|
|
211
|
+
const opened = await booting.bounded('store-open',
|
|
212
|
+
() => booting.request('data.open', { model: bootModel }));
|
|
213
|
+
if (superseded()) return;
|
|
214
|
+
acceptStore(opened, dispatch);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (superseded()) return;
|
|
219
|
+
const failure = bootFailure(booting.stage(), error);
|
|
220
|
+
booting.close();
|
|
221
|
+
transport = null;
|
|
222
|
+
dispatch('data/boot-error', failure.toJSON());
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (superseded()) return;
|
|
226
|
+
booting.settled();
|
|
227
|
+
booting.faults((fault) => dispatch('data/error', { message: fault.message }));
|
|
228
|
+
// any reopen — a recreate here, a migration anywhere — ends
|
|
229
|
+
// every live registration on the store, so the owner announces
|
|
230
|
+
// it and the pane takes its subscription out again. Without
|
|
231
|
+
// this, a live pane keeps its last rows and goes on looking
|
|
232
|
+
// live while nothing reaches it.
|
|
233
|
+
booting.notices((notice) => {
|
|
234
|
+
acceptStore(notice, dispatch);
|
|
235
|
+
subscribeLive(dispatch);
|
|
236
|
+
refreshRows(dispatch);
|
|
237
|
+
});
|
|
238
|
+
subscribeLive(dispatch);
|
|
239
|
+
await refreshRows(dispatch);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const effects = {
|
|
243
|
+
'data-boot': (_props, dispatch) => {
|
|
244
|
+
// seed the editor panes with the starting documents
|
|
245
|
+
dispatch('data/seed', {
|
|
246
|
+
modelText: JSON.stringify(DATA_MODEL, null, 2),
|
|
247
|
+
queryText: JSON.stringify(DATA_QUERY, null, 2),
|
|
248
|
+
tripCsv: TRIP_CSV,
|
|
249
|
+
});
|
|
250
|
+
return bootStore(dispatch);
|
|
251
|
+
},
|
|
252
|
+
// a retry after a terminal boot error: the same protocol, from a
|
|
253
|
+
// clean start — the failed transport was already released
|
|
254
|
+
'data-retry': (_props, dispatch) => bootStore(dispatch),
|
|
255
|
+
'data-resume': (_props, dispatch) => bootStore(dispatch, activeModel),
|
|
256
|
+
'data-open': (props, dispatch) => {
|
|
257
|
+
if (!withOwner(dispatch)) return Promise.resolve({ ok: false, error: 'Only the owning tab can recreate the store.' });
|
|
258
|
+
const model = parse(props.text, 'model');
|
|
259
|
+
if (!model.ok) {
|
|
260
|
+
dispatch('data/error', { message: model.message });
|
|
261
|
+
return Promise.resolve({ ok: false, error: model.message });
|
|
262
|
+
}
|
|
263
|
+
return transport?.request('data.open', { model: model.value, reset: true })
|
|
264
|
+
.then((opened) => {
|
|
265
|
+
acceptStore({ model: model.value, ...opened }, dispatch);
|
|
266
|
+
subscribeLive(dispatch);
|
|
267
|
+
return refreshRows(dispatch).then(() => ({ ok: true, model: model.value }));
|
|
268
|
+
})
|
|
269
|
+
.catch(error => { const message = String(error.message ?? error); dispatch('data/error', { message }); return { ok: false, error: message, code: error.code }; });
|
|
270
|
+
},
|
|
271
|
+
'data-insert': (props, dispatch) => {
|
|
272
|
+
if (!withStore(dispatch)) return;
|
|
273
|
+
const title = String(props?.title ?? '').trim();
|
|
274
|
+
if (title === '') return;
|
|
275
|
+
let doc;
|
|
276
|
+
try { doc = env.createRow ? env.createRow(title) : JSON.parse(title); }
|
|
277
|
+
catch (error) { dispatch('data/error', { message: `document: invalid JSON — ${error.message}` }); return; }
|
|
278
|
+
transport?.request('data.insert', { collection, doc })
|
|
279
|
+
.then(() => refreshRows(dispatch))
|
|
280
|
+
.catch((error) => dispatch('data/error', { message: String(error.message ?? error) }));
|
|
281
|
+
},
|
|
282
|
+
// the write half the live pane makes visible: a removal arrives there
|
|
283
|
+
// as an RFC 6902 remove, the same feed an insert arrives on
|
|
284
|
+
'data-delete': (props, dispatch) => {
|
|
285
|
+
if (!withStore(dispatch)) return;
|
|
286
|
+
transport?.request('data.delete', { collection, key: props.key })
|
|
287
|
+
.then(() => refreshRows(dispatch))
|
|
288
|
+
.catch((error) => dispatch('data/error', { message: String(error.message ?? error) }));
|
|
289
|
+
},
|
|
290
|
+
'data-run': (props, dispatch) => {
|
|
291
|
+
if (!withStore(dispatch)) return Promise.resolve({ ok: false, error: 'The store is not booted.' });
|
|
292
|
+
const query = parse(props.text, 'query');
|
|
293
|
+
if (!query.ok) { dispatch('data/error', { message: query.message }); return Promise.resolve({ ok: false, error: query.message }); }
|
|
294
|
+
const args = { collection, document: query.value, ...(props.externals ? { externals: props.externals } : {}) };
|
|
295
|
+
return Promise.all([transport.request('data.execute', args), transport.request('data.explain', args)])
|
|
296
|
+
.then(([value, explain]) => {
|
|
297
|
+
const results = Array.isArray(value) ? value : value == null ? [] : [value];
|
|
298
|
+
const plan = { sql: explain.sql, params: explain.params, indexes: explain.indexes, residual: explain.residual };
|
|
299
|
+
dispatch('data/results', { results, explain: plan });
|
|
300
|
+
return { ok: true, result: value, explain: plan };
|
|
301
|
+
})
|
|
302
|
+
.catch(error => {
|
|
303
|
+
const message = String(error.message ?? error); dispatch('data/error', { message });
|
|
304
|
+
return { ok: false, error: message, code: error.code };
|
|
305
|
+
});
|
|
306
|
+
},
|
|
307
|
+
// the third runner of the spatial corpus: every entry the site ships
|
|
308
|
+
// (the committed fixture, projected for a store) runs through THIS
|
|
309
|
+
// tab's wasm build in a throwaway in-memory store — under the model
|
|
310
|
+
// with the derived indexes and the one without — and is compared
|
|
311
|
+
// with what the JavaScript engine recorded. The e2e spec reads every
|
|
312
|
+
// answer off the page and asserts it against the fixture on disk
|
|
313
|
+
// itself; this effect executes, compares for the reader, and counts.
|
|
314
|
+
// A store refusal on an entry is a DISAGREEMENT here, never a skip:
|
|
315
|
+
// the only entries not run are the ones the corpus marks.
|
|
316
|
+
'data-oracle': (_props, dispatch) => {
|
|
317
|
+
if (env.corpus === undefined || transport === null) {
|
|
318
|
+
dispatch('data/oracle', { status: 'error', message: 'the spatial corpus is not available here' });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
dispatch('data/oracle', { status: 'running' });
|
|
322
|
+
env.corpus()
|
|
323
|
+
.then(async (loaded) => {
|
|
324
|
+
if (!loaded.ok) throw new Error(loaded.reason);
|
|
325
|
+
const corpus = loaded.value;
|
|
326
|
+
const results = [];
|
|
327
|
+
const disagreements = [];
|
|
328
|
+
for (const [mapping, model] of Object.entries(corpus.mappings)) {
|
|
329
|
+
for (const entry of corpus.entries) {
|
|
330
|
+
const where = `${EXECUTOR} (${mapping}) disagreed on ${entry.name}`
|
|
331
|
+
+ ` — query ${JSON.stringify(entry.query)}`;
|
|
332
|
+
let outcome;
|
|
333
|
+
try {
|
|
334
|
+
outcome = await /** @type {NonNullable<typeof transport>} */ (transport)
|
|
335
|
+
.request('data.oracle', {
|
|
336
|
+
model, collection: corpus.collection,
|
|
337
|
+
documents: entry.documents, query: entry.query,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
const message = String(/** @type {any} */ (error).message ?? error);
|
|
342
|
+
results.push({ mapping, name: entry.name, error: message, agreed: false });
|
|
343
|
+
disagreements.push(`${where}: refused — ${message}`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const agreed = agrees(entry, outcome);
|
|
347
|
+
results.push({ mapping, name: entry.name, answer: outcome.answer, empty: outcome.empty, agreed });
|
|
348
|
+
if (!agreed) {
|
|
349
|
+
disagreements.push(`${where}: recorded ${recorded(entry)}, answered `
|
|
350
|
+
+ `${outcome.empty ? 'the empty sequence' : JSON.stringify(outcome.answer)}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
dispatch('data/oracle', {
|
|
355
|
+
status: 'done',
|
|
356
|
+
executor: EXECUTOR,
|
|
357
|
+
source: corpus.source,
|
|
358
|
+
mappings: Object.keys(corpus.mappings),
|
|
359
|
+
entries: corpus.entries.length,
|
|
360
|
+
ran: results.length,
|
|
361
|
+
agreed: results.filter((result) => result.agreed).length,
|
|
362
|
+
skipped: corpus.skipped,
|
|
363
|
+
disagreements,
|
|
364
|
+
results,
|
|
365
|
+
});
|
|
366
|
+
})
|
|
367
|
+
.catch((error) => dispatch('data/oracle',
|
|
368
|
+
{ status: 'error', message: String(error.message ?? error) }));
|
|
369
|
+
},
|
|
370
|
+
// the whole round trip, where a reader can watch it: the pure half
|
|
371
|
+
// runs here (CSV → stylesheet → meta-schema → the linq document),
|
|
372
|
+
// the store half runs in the worker over a throwaway store with the
|
|
373
|
+
// derived spatial indexes, and its explain() comes back beside the
|
|
374
|
+
// answer so the pushdown is visible next to the result it produced
|
|
375
|
+
'data-trip': (props, dispatch) => {
|
|
376
|
+
if (transport === null) {
|
|
377
|
+
dispatch('data/trip', { status: 'error', message: 'the store is not ready yet' });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
let trip;
|
|
381
|
+
try {
|
|
382
|
+
trip = env.trip.pipeline(String(props?.csv ?? ''));
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
dispatch('data/trip', { status: 'error', message: String(/** @type {any} */ (error).message ?? error) });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (!trip.valid) {
|
|
389
|
+
const first = trip.errors[0];
|
|
390
|
+
dispatch('data/trip', {
|
|
391
|
+
status: 'error',
|
|
392
|
+
rows: trip.rows.length,
|
|
393
|
+
message: `the stylesheet's output is not GeoJSON by the meta-schema`
|
|
394
|
+
+ (first === undefined ? '' : ` \u2014 ${first.instancePath || '/'}: ${first.message}`),
|
|
395
|
+
errors: trip.errors,
|
|
396
|
+
});
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const counts = { rows: trip.rows.length, features: trip.documents.length };
|
|
400
|
+
dispatch('data/trip', { status: 'running', ...counts });
|
|
401
|
+
transport.request('data.oracle', {
|
|
402
|
+
model: trip.model, collection: trip.collectionName, documents: trip.documents,
|
|
403
|
+
query: trip.query, externals: trip.externals, explain: true,
|
|
404
|
+
})
|
|
405
|
+
.then((outcome) => dispatch('data/trip', {
|
|
406
|
+
status: 'done',
|
|
407
|
+
...counts,
|
|
408
|
+
query: trip.query,
|
|
409
|
+
results: outcome.empty ? []
|
|
410
|
+
: Array.isArray(outcome.answer) ? outcome.answer : [outcome.answer],
|
|
411
|
+
explain: outcome.explain ?? null,
|
|
412
|
+
}))
|
|
413
|
+
.catch((error) => dispatch('data/trip',
|
|
414
|
+
{ status: 'error', ...counts, message: String(error.message ?? error) }));
|
|
415
|
+
},
|
|
416
|
+
'data-migrate': (_props, dispatch) => {
|
|
417
|
+
if (!withOwner(dispatch)) return;
|
|
418
|
+
// the worked migration: index the title member, shadow-verified.
|
|
419
|
+
// The reopen it ends with drops every live registration, and the
|
|
420
|
+
// owner's notice is what puts the pane's subscription back.
|
|
421
|
+
if (!env.migration) { dispatch('data/error', { message: 'This host has no migration configured.' }); return; }
|
|
422
|
+
const { to, id } = env.migration(activeModel, collection);
|
|
423
|
+
return transport?.request('data.migrate', { to, id })
|
|
424
|
+
.then((report) => {
|
|
425
|
+
activeModel = to;
|
|
426
|
+
dispatch('data/migrated', { report });
|
|
427
|
+
})
|
|
428
|
+
.catch((error) => dispatch('data/error', { message: String(error.message ?? error) }));
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
let booted = false, seeded = false;
|
|
433
|
+
function release() {
|
|
434
|
+
generation++; liveSub?.stop(); liveSub = null; transport?.close(); transport = null; booted = false;
|
|
435
|
+
}
|
|
436
|
+
function dispose() {
|
|
437
|
+
if (disposed) return;
|
|
438
|
+
disposed = true; release();
|
|
439
|
+
lifecycleTarget.removeEventListener?.('pagehide', releaseLive);
|
|
440
|
+
lifecycleTarget.removeEventListener?.('beforeunload', releaseLive);
|
|
441
|
+
}
|
|
442
|
+
// Every asynchronous callback belongs to the activation that started it.
|
|
443
|
+
// A departed route, disposed mount or replacement boot cannot publish late results.
|
|
444
|
+
for (const [name, effect] of Object.entries(effects)) {
|
|
445
|
+
effects[name] = (props, dispatch) => {
|
|
446
|
+
if (disposed) return Promise.resolve({ ok: false, error: 'The Data editor is disposed.' });
|
|
447
|
+
if (['data-boot', 'data-retry', 'data-resume'].includes(name)) generation++;
|
|
448
|
+
const current = generation;
|
|
449
|
+
return effect(props, (action, payload) => {
|
|
450
|
+
if (!disposed && current === generation) dispatch(action, payload);
|
|
451
|
+
});
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
effects['data-boot'].dispose = dispose;
|
|
455
|
+
const ownerSub = (_props, dispatch) => {
|
|
456
|
+
if (disposed || booted) return;
|
|
457
|
+
booted = true;
|
|
458
|
+
dispatch(seeded ? 'data/resume' : 'data/boot'); seeded = true;
|
|
459
|
+
return release;
|
|
460
|
+
};
|
|
461
|
+
return { effects, ownerSub, dispose };
|
|
462
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Isolated Data state; the boot operation seeds the editable documents. */
|
|
3
|
+
export function createDataState() {
|
|
4
|
+
return {
|
|
5
|
+
status: 'boot', // 'boot' | 'ready' | 'error'
|
|
6
|
+
boot: null, // the terminal boot failure { code: 'DATA_BOOT', stage, message }, or null
|
|
7
|
+
topology: '—', // 'owner' | 'client'
|
|
8
|
+
vfs: '—', // 'opfs-sahpool' | 'memory'
|
|
9
|
+
version: '',
|
|
10
|
+
capture: '—', // 'journal' on wasm (sessions not adapted)
|
|
11
|
+
operators: [], // registered operator vocabulary (math/finance/stats packs)
|
|
12
|
+
pushableOperators: [], // the subset pushed to SQLite as deterministic UDFs
|
|
13
|
+
refusal: null, // the JD2061 second-writer message, if any
|
|
14
|
+
modelText: '', // the editable model document (JSON)
|
|
15
|
+
queryText: '', // the editable query document (JSON)
|
|
16
|
+
// the collection every effect works on and the key pointer its
|
|
17
|
+
// model declares — the model pane is editable, so both move with
|
|
18
|
+
// the model rather than being named anywhere
|
|
19
|
+
collection: '',
|
|
20
|
+
keyPointer: '/id',
|
|
21
|
+
rows: [], // the whole collection, last read
|
|
22
|
+
results: [], // the last query() result
|
|
23
|
+
explain: null, // the last explain() { sql, params, indexes, residual }
|
|
24
|
+
live: { rows: [], seq: null, regs: null },
|
|
25
|
+
// the insert field's buffer. A controlled input whose value is not
|
|
26
|
+
// published per keystroke is erased by the next render, and this
|
|
27
|
+
// page renders on every live-query event — so the title had to
|
|
28
|
+
// live in state, not only in the DOM.
|
|
29
|
+
insertDraft: '',
|
|
30
|
+
migration: null, // the last planned/applied migration report
|
|
31
|
+
// the spatial corpus run through THIS tab's store as the third
|
|
32
|
+
// executor (data.oracle, one throwaway store per entry): null until
|
|
33
|
+
// asked, then { status: 'running' } and the report
|
|
34
|
+
oracle: null,
|
|
35
|
+
// the spatial round trip (CSV → stylesheet → meta-schema → a
|
|
36
|
+
// throwaway store with derived spatial indexes → a linq $within →
|
|
37
|
+
// explain() → a map), run from the fourth card: the editable CSV,
|
|
38
|
+
// and the last report (null until asked, then { status, … })
|
|
39
|
+
trip: { csv: '', report: null },
|
|
40
|
+
error: null,
|
|
41
|
+
// the phone layout: which single card shows (store | query | live |
|
|
42
|
+
// trip). Query is the default — it is what a reader of this page
|
|
43
|
+
// came for.
|
|
44
|
+
mobilePane: 'query',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** The browser storage ladder reports every observed fallback. */
|
|
3
|
+
|
|
4
|
+
/** An OPFS acquisition refusal is different from an unavailable API.
|
|
5
|
+
* Wrapped host errors retain that distinction through their cause.
|
|
6
|
+
* @param {any} error @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
function accessHandleHeld(error) {
|
|
9
|
+
const seen = new Set();
|
|
10
|
+
while (error !== null && typeof error === 'object' && !seen.has(error)) {
|
|
11
|
+
if (error.name === 'NoModificationAllowedError') return true;
|
|
12
|
+
seen.add(error);
|
|
13
|
+
error = error.cause;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** @param {{ isolated: boolean, sharedArrayBuffer: boolean,
|
|
19
|
+
* sab: () => Promise<any>, sah: () => Promise<any>, indexedDB: () => Promise<any> }} probes
|
|
20
|
+
* @returns {Promise<any>}
|
|
21
|
+
*/
|
|
22
|
+
export async function selectBrowserStorage(probes) {
|
|
23
|
+
const failures = [];
|
|
24
|
+
for (const [vfs, probe] of [
|
|
25
|
+
['opfs-sab', probes.sab], ['opfs-sahpool', probes.sah], ['indexeddb-snapshot', probes.indexedDB],
|
|
26
|
+
]) {
|
|
27
|
+
if (vfs === 'opfs-sab' && (!probes.isolated || !probes.sharedArrayBuffer)) {
|
|
28
|
+
failures.push({ vfs, reason: !probes.isolated ? 'cross-origin isolation is unavailable' : 'SharedArrayBuffer is unavailable' });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const adapter = await probe();
|
|
33
|
+
return { ...adapter, vfs, durable: true, failures };
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
failures.push({ vfs, reason: String(error?.message ?? error) });
|
|
37
|
+
if (vfs.startsWith('opfs-') && accessHandleHeld(error))
|
|
38
|
+
return { vfs: 'owner-selected', durable: false, held: true, failures };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { vfs: 'memory', durable: false, failures };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Discover a peer after storage acquisition failed without a Web Lock.
|
|
45
|
+
* Silence says only that no peer answered in time. When OPFS reported a
|
|
46
|
+
* held handle, it cannot authorize a private memory fallback.
|
|
47
|
+
* @param {{ held?: boolean }} selected
|
|
48
|
+
* @param {(ms: number) => Promise<false | { vfs: string }>} ping
|
|
49
|
+
* @returns {Promise<false | { vfs: string }>}
|
|
50
|
+
*/
|
|
51
|
+
export async function discoverStorageOwner(selected, ping) {
|
|
52
|
+
const owner = await ping(600) || await ping(3000);
|
|
53
|
+
if (owner) return owner;
|
|
54
|
+
if (selected.held) {
|
|
55
|
+
const error = new Error('OPFS refused an access handle because it is held by another context;'
|
|
56
|
+
+ ' no studio owner answered within either discovery window. Close the other context or retry when it is idle.');
|
|
57
|
+
/** @type {any} */ (error).code = 'JD2061';
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|