@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,349 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The data studio's handler table, apart from the browser that
4
+ * hosts it.
5
+ *
6
+ * `db-worker.js` is the OWNER context: it loads the SQLite wasm build,
7
+ * claims the OPFS access-handle pool and serves two transports. None of
8
+ * that is testable outside a browser, and none of it is where the
9
+ * studio's behaviour lives — so the behaviour is here, over an injected
10
+ * host, and the worker supplies the real one. The same table then runs
11
+ * under a Node driver in the test suite, which is how the store rules
12
+ * below are held to anything at all.
13
+ *
14
+ * What the table owns, and the worker deliberately does not:
15
+ *
16
+ * - **The live registry.** Every `data.live` registration is held in a
17
+ * set, and `data.lives` reports its SIZE. A count kept as a number
18
+ * beside the registrations drifts the moment a reopen zeroes it while
19
+ * subscriptions are still held — under-reporting forever, and going
20
+ * negative on the next release, at which point the count fails its own
21
+ * output schema and freezes at its last good value. A frozen count
22
+ * reads exactly like a healthy one, which is why this is a set.
23
+ * - **Reopening releases.** Closing the store releases every registration
24
+ * it fed, so the count comes back to zero because the subscriptions
25
+ * ended, not because someone assigned zero.
26
+ * - **A refused migration does not advance the model.** `state.model`
27
+ * moves only when `migrate()` returned; a store reopened after a
28
+ * refusal is reopened on the model it still has, and the refusal
29
+ * crosses as the declared `db` failure.
30
+ * - **Peers are told.** Any reopen — open, recreate, migrate — drops the
31
+ * live registrations of every tab, so the worker announces it and the
32
+ * boundaries resubscribe. Silence would leave every live pane showing
33
+ * its last rows forever while looking live.
34
+ * - **Recreating is owner-only.** `reset` unlinks the database the owner
35
+ * holds; a client tab asking for it is refused rather than served, so
36
+ * the control that is honest in the owning tab cannot be destructive
37
+ * from another one.
38
+ * - **The oracle's store is throwaway.** `data.oracle` opens a fresh
39
+ * in-memory store on the model it is handed, answers one query over
40
+ * the documents it is handed (with its plan, when asked), and closes
41
+ * it — the store this context holds, its registrations and its peers
42
+ * are untouched. That is what
43
+ * lets the committed spatial corpus hold SQLite-in-wasm to the
44
+ * JavaScript engine from any tab, including one with no OPFS at all.
45
+ */
46
+
47
+ import { openStore, migrate, planModelMigration } from '@jarenjs/db';
48
+ import { ContractFailure } from '@jarenjs/contract';
49
+
50
+ /**
51
+ * The host the table runs over: everything that differs between a
52
+ * browser worker holding an OPFS pool and a test holding a file.
53
+ * @typedef {Object} DataHost
54
+ * @property {() => Promise<any>} init - Decide the topology and prepare
55
+ * this context; resolves to the `data.init` answer.
56
+ * @property {() => any} makeDriver - A fresh driver over a fresh handle.
57
+ * @property {() => any} makeScratchDriver - A driver over a throwaway
58
+ * in-memory handle: the oracle's store, opened and closed per call,
59
+ * never the database this context holds.
60
+ * @property {() => string} path - The database path for this context.
61
+ * @property {() => string} vfs - The VFS this context settled on.
62
+ * @property {() => boolean} durable - Whether a migration can be applied
63
+ * here at all; a memory store recreates instead.
64
+ * @property {() => void} unlink - Drop the persistent database.
65
+ * @property {(notice: any) => void} announce - Tell every peer the store
66
+ * was reopened under them.
67
+ * @property {any} operators - The operator registry the store mounts.
68
+ */
69
+
70
+ /**
71
+ * The details of the declared `db` failure: the store's own coded refusal
72
+ * and its message.
73
+ *
74
+ * The contract carries string codes, including numeric DOMException
75
+ * codes converted to strings. The host's message survives unchanged.
76
+ * @param {unknown} error
77
+ * @returns {{ code: string | null, message: string }}
78
+ */
79
+ export function wireError(error) {
80
+ const raised = /** @type {any} */ (error);
81
+ const code = raised?.code;
82
+ const message = String(raised?.message ?? error);
83
+ return { code: code === undefined || code === null ? null : String(code), message };
84
+ }
85
+
86
+ /**
87
+ * A refusal only the owning context can answer.
88
+ * @param {string} message
89
+ * @returns {Error}
90
+ */
91
+ function ownerOnly(message) {
92
+ const error = new Error(message);
93
+ /** @type {any} */ (error).code = 'JD2061';
94
+ return error;
95
+ }
96
+
97
+ /**
98
+ * The studio's handler table over an injected host.
99
+ * @param {DataHost} host
100
+ * @returns {{ handlers: Record<string, any>, clientHandlers: Record<string, any>, state: any, dispose: () => Promise<void> }}
101
+ */
102
+ export function createDataHandlers(host) {
103
+ let disposed = false;
104
+ function active() {
105
+ if (disposed) throw Object.assign(new Error('the data host is disposed'), { code: 'JD2005' });
106
+ }
107
+ /** An asynchronous open must release its handle if the host left meanwhile. */
108
+ async function acceptStore(next) {
109
+ if (disposed) { await next.close(); active(); }
110
+ state.store = next;
111
+ }
112
+ const state = {
113
+ /** @type {any} */ store: null,
114
+ /** @type {any} */ model: null,
115
+ /** Live registrations, held rather than counted (see the file note). */
116
+ /** @type {Set<any>} */ lives: new Set(),
117
+ };
118
+
119
+ /** Release one registration exactly once, whichever side asks. */
120
+ const releaser = (/** @type {any} */ entry) => () => {
121
+ if (!state.lives.delete(entry)) return;
122
+ entry.live.close();
123
+ };
124
+
125
+ /** The open store, or the coded refusal that says why there is none.
126
+ * An open that failed leaves this context with no store at all, and
127
+ * every later operation reading through a null one reported a host
128
+ * TypeError — a defect message for a state the studio can explain. */
129
+ function store() {
130
+ active();
131
+ if (state.store === null) {
132
+ const error = new Error('the store is not open: the last open was refused,'
133
+ + ' so recreate it from the model pane');
134
+ /** @type {any} */ (error).code = 'JD2005';
135
+ throw error;
136
+ }
137
+ return state.store;
138
+ }
139
+
140
+ /** Close the store, ending every subscription it was feeding. */
141
+ const closeStore = async () => {
142
+ if (state.store === null) return;
143
+ for (const entry of [...state.lives]) entry.stop();
144
+ const store = state.store;
145
+ state.store = null;
146
+ await store.close();
147
+ };
148
+
149
+ /**
150
+ * Open (or recreate) the store on a model. Everything that reopens goes
151
+ * through here, so nothing can reopen without releasing first.
152
+ * @param {any} args
153
+ */
154
+ async function open(args) {
155
+ await closeStore();
156
+ active();
157
+ if (args.reset === true) await host.unlink();
158
+ active();
159
+ state.model = args.model;
160
+ await acceptStore(await openStore(args.model, {
161
+ driver: host.makeDriver(), path: host.path(), capture: true, operators: host.operators,
162
+ }));
163
+ const opened = storeInfo();
164
+ host.announce({ store: 'opened', reset: args.reset === true, ...opened });
165
+ return opened;
166
+ }
167
+
168
+ /** The model and capabilities every attached tab must follow. */
169
+ function storeInfo() {
170
+ const capabilities = store().capabilities;
171
+ const collection = Object.keys(state.model.collections)[0];
172
+ return {
173
+ vfs: host.vfs(),
174
+ model: state.model,
175
+ collection,
176
+ keyPointer: state.model.collections[collection].key ?? '/id',
177
+ capabilities: {
178
+ version: capabilities.version,
179
+ capture: capabilities.capture,
180
+ live: capabilities.live,
181
+ userFunctions: capabilities.userFunctions,
182
+ operators: capabilities.operators,
183
+ pushableOperators: capabilities.pushableOperators,
184
+ },
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Plan a migration, apply it, and reopen. The store is reopened either
190
+ * way — on the new model when the migration returned, on the model it
191
+ * still has when it did not — and a refusal is re-thrown so it reaches
192
+ * the caller as the declared failure rather than as a report of work
193
+ * that never happened.
194
+ * @param {any} args
195
+ */
196
+ async function migrateTo(args) {
197
+ const baseline = state.model;
198
+ const { migration, report } = planModelMigration(baseline, args.to,
199
+ { dialect: store().dialect, id: args.id ?? 'studio-migration' });
200
+ const rendered = migration.steps.map((/** @type {any} */ step) => step.sql ?? step.kind);
201
+ await closeStore();
202
+ active();
203
+ const driver = host.makeDriver();
204
+ const path = host.path();
205
+ /** @type {any} */
206
+ let applied = null;
207
+ /** @type {unknown} */
208
+ let refused = null;
209
+ try {
210
+ applied = host.durable()
211
+ ? await migrate({ driver, path }, [migration],
212
+ { baseline, model: args.to, shadow: true })
213
+ : { applied: [], note: 'memory stores recreate instead of migrating' };
214
+ }
215
+ catch (error) {
216
+ refused = error;
217
+ }
218
+ active();
219
+ state.model = refused === null ? args.to : baseline;
220
+ await acceptStore(await openStore(state.model,
221
+ { driver, path, capture: true, operators: host.operators }));
222
+ host.announce({ store: 'migrated', applied: refused === null, ...storeInfo() });
223
+ if (refused !== null) throw refused;
224
+ const note = applied.note;
225
+ return {
226
+ planned: rendered,
227
+ losses: report?.losses ?? [],
228
+ // the runner answers the ids it applied, as strings — reading an
229
+ // `id` member off them published a list of nulls under a
230
+ // declaration of strings, and the page counted them as successes
231
+ applied: applied.applied ?? [],
232
+ ...(note !== undefined ? { note } : {}),
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Subscribe a live query, as the stream binding's duck-typed
238
+ * subscription. The registration is held so the count is its size and
239
+ * so a reopen can end it.
240
+ * @param {any} input
241
+ */
242
+ async function live(input) {
243
+ const query = await store().collection(input.collection)
244
+ .live(input.document, { externals: input.externals ?? {} });
245
+ if (disposed) { query.close(); active(); }
246
+ /** @type {any} */
247
+ const entry = { live: query };
248
+ entry.stop = releaser(entry);
249
+ state.lives.add(entry);
250
+ return {
251
+ snapshot: () => query.result,
252
+ subscribe: (/** @type {(emission: any) => void} */ cb) => query.subscribe(cb),
253
+ close: () => entry.stop(),
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Run one query over a throwaway store: a fresh in-memory store on
259
+ * the given model, the documents inserted, the query executed, and
260
+ * the store closed whatever happened. Nothing here reads or writes
261
+ * the store this context holds, which is what lets a second executor
262
+ * be held to the engine's recorded answers from any tab — the corpus
263
+ * needs execution, not persistence.
264
+ *
265
+ * The empty sequence is `undefined`, which JSON cannot carry and
266
+ * `null` must not stand in for (null is an answer the engine can
267
+ * record). It crosses as a flag beside a null answer.
268
+ * @param {any} input
269
+ */
270
+ async function oracle(input) {
271
+ const scratch = await openStore(input.model,
272
+ { driver: host.makeScratchDriver(), path: ':memory:', operators: host.operators });
273
+ try {
274
+ const rows = scratch.collection(input.collection);
275
+ for (const document of input.documents) await rows.insert(document);
276
+ const options = { externals: input.externals ?? {} };
277
+ const answer = await rows.execute(input.query, options);
278
+ /** @type {any} */
279
+ const out = { answer: answer === undefined ? null : answer, empty: answer === undefined };
280
+ // the plan beside the answer, when asked: the same record
281
+ // `data.explain` gives over the studio's store, so a reader can
282
+ // see the pushdown a throwaway store ran, not only what it said
283
+ if (input.explain === true) out.explain = await rows.explain(input.query, options);
284
+ return out;
285
+ }
286
+ finally {
287
+ await scratch.close();
288
+ }
289
+ }
290
+
291
+ /** Every store rejection crosses as the declared `db` failure, so a
292
+ * genuine host bug is the only thing that answers the binding's JC2070.
293
+ * @param {(input: any) => any} fn */
294
+ const guard = (fn) => async (/** @type {any} */ input) => {
295
+ try {
296
+ active();
297
+ return await fn(input);
298
+ }
299
+ catch (error) {
300
+ return ContractFailure('db', {}, wireError(error));
301
+ }
302
+ };
303
+
304
+ const collection = (/** @type {any} */ input) => store().collection(input.collection);
305
+
306
+ /** The operations, before either transport's policy is applied. */
307
+ const operations = {
308
+ 'data.init': () => host.init(),
309
+ 'data.open': (/** @type {any} */ input) => open(input),
310
+ 'data.insert': (/** @type {any} */ input) => collection(input).insert(input.doc),
311
+ 'data.delete': (/** @type {any} */ input) => collection(input).delete(input.key),
312
+ 'data.rows': (/** @type {any} */ input) => collection(input)
313
+ .execute([{ $for: { it: '$[*]' }, $return: '$it' }]),
314
+ 'data.execute': (/** @type {any} */ input) => collection(input)
315
+ .execute(input.document, { externals: input.externals ?? {} }),
316
+ 'data.explain': (/** @type {any} */ input) => collection(input)
317
+ .explain(input.document, { externals: input.externals ?? {} }),
318
+ 'data.oracle': (/** @type {any} */ input) => oracle(input),
319
+ 'data.live': (/** @type {any} */ input) => live(input),
320
+ 'data.lives': () => ({ count: state.lives.size }),
321
+ 'data.migrate': (/** @type {any} */ input) => migrateTo(input),
322
+ };
323
+
324
+ const handlers = Object.fromEntries(
325
+ Object.entries(operations).map(([id, fn]) => [id, guard(fn)]));
326
+
327
+ return {
328
+ handlers,
329
+ // Refuse new calls immediately; an in-flight open closes its own late handle.
330
+ dispose: () => { disposed = true; return closeStore(); },
331
+ // The channel a CLIENT tab reaches the owner on serves the same store
332
+ // through the same table. A client open only attaches: it reads the
333
+ // current model without closing the owner's connection or live feeds.
334
+ clientHandlers: {
335
+ ...handlers,
336
+ 'data.open': guard((/** @type {any} */ input) => {
337
+ if (input.reset === true) {
338
+ throw ownerOnly('recreating the store unlinks the database the owning tab holds:'
339
+ + ' run it in that tab, not from a client');
340
+ }
341
+ return storeInfo();
342
+ }),
343
+ 'data.migrate': guard(() => {
344
+ throw ownerOnly('only the owning tab can migrate the store; run the migration in that tab');
345
+ }),
346
+ },
347
+ state,
348
+ };
349
+ }
@@ -0,0 +1,8 @@
1
+ //@ts-check
2
+ export { createDataHandlers, wireError } from './handlers.js';
3
+ export { dataContract, dataContractDocument } from './contract.js';
4
+ export { BOOT_STAGES, BOOT_ERROR_CODE, DEFAULT_BOOT_BUDGETS, DataBootError, bootFailure, resolveBootBudgets, createStageRunner } from './boot-stages.js';
5
+ export { selectBrowserStorage, discoverStorageOwner } from './storage.js';
6
+ /** @typedef {import('./handlers.js').DataHost} DataHost */
7
+ export { createBrowserDataWorker } from './browser-worker.js';
8
+ export { createProjectDataWorker } from './project-worker.js';
@@ -0,0 +1,26 @@
1
+ //@ts-check
2
+ /** A project model's private memory store over the same public Data contract. */
3
+ import { wasmDriver, sqlite3Handle } from '@jarenjs/db/wasm';
4
+ import { servePort } from '@jarenjs/contract/port';
5
+ import { createDataHandlers } from './handlers.js';
6
+ import { dataContract } from './contract.js';
7
+ /** @param {{ initialize: () => Promise<any>, scope: any, operators?: any }} env */
8
+ export function createProjectDataWorker(env) {
9
+ let sqlite3;
10
+ let disposed = false;
11
+ const ready = env.initialize();
12
+ const table = createDataHandlers({
13
+ init: async () => {
14
+ sqlite3 = await ready;
15
+ if (disposed) throw new Error('The project Data worker is disposed.');
16
+ return { topology: 'memory', vfs: 'memory', version: sqlite3.version.libVersion };
17
+ },
18
+ makeDriver: () => wasmDriver(sqlite3Handle(sqlite3)),
19
+ makeScratchDriver: () => wasmDriver(sqlite3Handle(sqlite3)),
20
+ path: () => ':memory:', vfs: () => 'memory', durable: () => false,
21
+ unlink: () => {}, announce: () => {},
22
+ operators: env.operators,
23
+ });
24
+ const server = servePort(dataContract, table.handlers, { channel: env.scope });
25
+ return { dispose() { disposed = true; server.close(); return table.dispose(); } };
26
+ }