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