@tlockney/cdp-kernel 0.11.0 → 0.12.1

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/dist/package.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /** Kept equal to package.json and deno.jsonc by test/package.test.ts. */
2
- export declare const VERSION = "0.11.0";
2
+ export declare const VERSION = "0.12.1";
3
3
  /**
4
4
  * The UTC day this version was released, set with VERSION. The publish
5
5
  * workflow refuses a tag unless it's today or yesterday, so it can't go
6
6
  * stale: see dependencyCutoff.
7
7
  */
8
- export declare const RELEASE_DATE = "2026-11-13";
8
+ export declare const RELEASE_DATE = "2026-11-14";
9
9
  /**
10
10
  * Deno blocks packages less than 24 hours old (minimum dependency age), so
11
11
  * a kernelspec relaunching a release from JSR would fail on release day. It
@@ -14,7 +14,7 @@ export declare const RELEASE_DATE = "2026-11-13";
14
14
  * late in the day its tag was pushed), and nothing newer ever is.
15
15
  */
16
16
  export declare function dependencyCutoff(releaseDate?: string): string;
17
- export declare const RUNTIME_FILES: readonly ["bootstrap.mjs", "bun-channel.mjs", "evaluator.mjs", "introspect.mjs", "workerd-worker.mjs", "display.mjs", "comm.mjs", "host.mjs", "widget.mjs", "table.mjs", "sql.mjs", "chart.mjs", "node-hooks.mjs", "deno-compat.mjs", "node-modules-resolve.mjs"];
17
+ export declare const RUNTIME_FILES: readonly ["bootstrap.mjs", "bun-channel.mjs", "evaluator.mjs", "introspect.mjs", "workerd-worker.mjs", "display.mjs", "comm.mjs", "host.mjs", "widget.mjs", "table.mjs", "table-paging.mjs", "sql.mjs", "chart.mjs", "node-hooks.mjs", "deno-compat.mjs", "node-modules-resolve.mjs"];
18
18
  export type HostRuntime = "node" | "deno";
19
19
  export declare function hostRuntime(): HostRuntime;
20
20
  /** Where the CLI module was loaded from. */
package/dist/package.js CHANGED
@@ -14,13 +14,13 @@ import { join, sep } from "node:path";
14
14
  import process from "node:process";
15
15
  import { fileURLToPath } from "node:url";
16
16
  /** Kept equal to package.json and deno.jsonc by test/package.test.ts. */
17
- export const VERSION = "0.11.0";
17
+ export const VERSION = "0.12.1";
18
18
  /**
19
19
  * The UTC day this version was released, set with VERSION. The publish
20
20
  * workflow refuses a tag unless it's today or yesterday, so it can't go
21
21
  * stale: see dependencyCutoff.
22
22
  */
23
- export const RELEASE_DATE = "2026-11-13";
23
+ export const RELEASE_DATE = "2026-11-14";
24
24
  /**
25
25
  * Deno blocks packages less than 24 hours old (minimum dependency age), so
26
26
  * a kernelspec relaunching a release from JSR would fail on release day. It
@@ -46,6 +46,7 @@ export const RUNTIME_FILES = [
46
46
  "host.mjs",
47
47
  "widget.mjs",
48
48
  "table.mjs",
49
+ "table-paging.mjs",
49
50
  "sql.mjs",
50
51
  "chart.mjs",
51
52
  "node-hooks.mjs",
@@ -35,7 +35,7 @@ import { connectUnixWebSocket } from "./ws-client.js";
35
35
  import { findWranglerConfig, startMiniflare } from "./workers-project.js";
36
36
  import { ensureInstalledAsync, findLockFile, jsrToNpm, parsePackageSpecifier } from "../runtime/node-hooks.mjs";
37
37
  /** The driver Worker's modules; the first is its main module. */
38
- export const WORKER_MODULES = ["workerd-worker.mjs", "display.mjs", "comm.mjs", "host.mjs", "widget.mjs", "table.mjs", "sql.mjs", "chart.mjs", "evaluator.mjs", "introspect.mjs"];
38
+ export const WORKER_MODULES = ["workerd-worker.mjs", "display.mjs", "comm.mjs", "host.mjs", "widget.mjs", "table.mjs", "table-paging.mjs", "sql.mjs", "chart.mjs", "evaluator.mjs", "introspect.mjs"];
39
39
  /** See BunTarget: how long an interrupted cell has to show it isn't busy. */
40
40
  export const INTERRUPT_GRACE_MS = 500;
41
41
  export function restartNote(runtime) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tlockney/cdp-kernel",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "Deno's Jupyter kernel, extracted to run notebooks on Node, Deno, Bun, workerd and celld",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Lockney <thomas@lockney.net>",
@@ -72,6 +72,8 @@ let paramsText;
72
72
 
73
73
  // --- Modules ----------------------------------------------------------------
74
74
  let cdn = DEFAULT_CDN;
75
+ /** Specifiers the page serves itself, such as jupyter.chart's libraries in the Ganymede apps. */
76
+ let modules;
75
77
 
76
78
 
77
79
  // --- The host page (#384) -----------------------------------------------------
@@ -153,13 +155,17 @@ async function preload(urls, base) {
153
155
  // --- The shim cells and the evaluator use -----------------------------------
154
156
  let handle = null;
155
157
 
156
- function install({ base, cdn: cdnBase, params }) {
158
+ function install({ base, cdn: cdnBase, modules: moduleMap, params }) {
157
159
  if (typeof params === "string") paramsText = params;
158
160
  if (typeof cdnBase === "string") cdn = cdnBase.endsWith("/") ? cdnBase : cdnBase + "/";
161
+ if (moduleMap !== null && typeof moduleMap === "object") modules = { ...moduleMap };
159
162
  const url = new URL("$cdpk$repl.mjs", base).href;
160
163
  globalThis.__cdpk = {
161
- import: (specifier, options) => import(resolveSpecifier(specifier, url, cdn), options),
162
- meta: { url, resolve: (specifier) => resolveSpecifier(specifier, url, cdn) },
164
+ import: (specifier, options) => import(resolveSpecifier(specifier, url, cdn, modules), options),
165
+ meta: { url, resolve: (specifier) => resolveSpecifier(specifier, url, cdn, modules) },
166
+ // The URL `modules` maps a specifier to, or null: jupyter.sql's DuckDB
167
+ // files load from the page this way.
168
+ mapped: (specifier) => (modules && Object.hasOwn(modules, specifier) ? new URL(modules[specifier], url).href : null),
163
169
  // inspect_request (#10): by descriptor, so nothing runs.
164
170
  describeForInspect: (value, detail) => describeValue(value, { inspect: (v) => inspect(v, { depth: 2 }), detail }),
165
171
  inspectPath: (path, detail) => inspectPath(path, { inspect: (v) => inspect(v, { depth: 2 }), detail }),
package/runtime/cdn.d.mts CHANGED
@@ -1,2 +1,7 @@
1
1
  export const DEFAULT_CDN: string;
2
- export function resolveSpecifier(specifier: string, base: string, cdn?: string): string;
2
+ export function resolveSpecifier(
3
+ specifier: string,
4
+ base: string,
5
+ cdn?: string,
6
+ modules?: { readonly [specifier: string]: string },
7
+ ): string;
package/runtime/cdn.mjs CHANGED
@@ -6,8 +6,13 @@
6
6
 
7
7
  export const DEFAULT_CDN = "https://esm.sh/";
8
8
 
9
- /** The URL `specifier`, imported from `base`, loads from. */
10
- export function resolveSpecifier(specifier, base, cdn = DEFAULT_CDN) {
9
+ /**
10
+ * The URL `specifier`, imported from `base`, loads from. `modules` maps
11
+ * specifiers, as written, to URLs (resolved against `base`) that win over
12
+ * the CDN: an app ships the packages it needs offline this way.
13
+ */
14
+ export function resolveSpecifier(specifier, base, cdn = DEFAULT_CDN, modules = undefined) {
15
+ if (modules && Object.hasOwn(modules, specifier)) return new URL(modules[specifier], base).href;
11
16
  if (/^(?:https?|data|blob):/.test(specifier)) return specifier;
12
17
  if (/^\.{0,2}\//.test(specifier)) return new URL(specifier, base).href;
13
18
  if (specifier.startsWith("npm:")) return cdn + specifier.slice(4);
@@ -83,33 +83,6 @@ export interface Displayable {
83
83
  * as `Deno.jupyter` in `deno jupyter`.
84
84
  */
85
85
  export interface JupyterNamespace {
86
- /**
87
- * Send a raw Jupyter message on the kernel's output channel (IOPub), as a
88
- * reply to the running cell. The other helpers are built on it. It does
89
- * not check the message: a wrong field is ignored or mishandled by the
90
- * frontend.
91
- *
92
- * @param msgType An IOPub message type, such as `"clear_output"` or
93
- * `"display_data"`.
94
- * @param content The message content, as the messaging spec defines it
95
- * for `msgType`.
96
- * @param options `metadata` for the message, and binary `buffers`.
97
- * @example
98
- * ```ts
99
- * // Count down in place: clear_output with wait avoids a flicker.
100
- * for (const n of [3, 2, 1]) {
101
- * jupyter.broadcast("clear_output", { wait: true });
102
- * await jupyter.display(jupyter.md`Launching in **${n}**…`);
103
- * await new Promise((resolve) => setTimeout(resolve, 1000));
104
- * }
105
- * ```
106
- * @see jupyter-api.md#raw-messages-with-broadcast
107
- */
108
- broadcast(
109
- msgType: string,
110
- content: object,
111
- options?: { metadata?: object; buffers?: Uint8Array[] },
112
- ): void;
113
86
  /**
114
87
  * Show a value now, in the middle of a cell, or replace one shown
115
88
  * earlier. The value can be anything a cell could end with: a template
@@ -142,22 +115,6 @@ export interface JupyterNamespace {
142
115
  * @see jupyter-api.md#showing-output-with-display
143
116
  */
144
117
  display(obj: unknown, options?: DisplayOptions): Promise<void>;
145
- /**
146
- * The MIME bundle that displaying a value would send, without showing
147
- * anything. For plain values `text/plain` holds terminal color codes,
148
- * as the notebook shows them.
149
- *
150
- * @param obj The value to format.
151
- * @returns The bundle, for example `{ "text/plain": "\x1b[33m42\x1b[39m" }`
152
- * for `42`.
153
- * @example
154
- * ```ts
155
- * const bundle = await jupyter.format(jupyter.md`# Weekly report`);
156
- * bundle["text/markdown"]; // "# Weekly report"
157
- * ```
158
- * @see jupyter-api.md#getting-a-bundle-with-format
159
- */
160
- format(obj: unknown): Promise<MediaBundle>;
161
118
  /**
162
119
  * A tagged template that renders as Markdown. Interpolated values are
163
120
  * inserted as is, without escaping.
@@ -250,109 +207,48 @@ export interface JupyterNamespace {
250
207
  */
251
208
  $display: typeof $display;
252
209
  /**
253
- * Jupyter comms: message channels to the frontend outside cell
254
- * execution. Its methods are in the widgets and comms reference.
255
- *
256
- * @see widgets.md#comms
257
- */
258
- comm: CommApi;
259
- /**
260
- * Create an anywidget-compatible widget model. Display it (or end the
261
- * cell with it) to show a view.
262
- *
263
- * @param options The view module, its CSS, and the initial state.
264
- * @returns The model, synced with every view of it.
265
- * @throws {TypeError} `options.esm` is not a string.
266
- * @availability The frontend needs the anywidget extension.
267
- * @example
268
- * ```ts
269
- * const counter = jupyter.widget({
270
- * esm: `export default {
271
- * render({ model, el }) {
272
- * const button = document.createElement("button");
273
- * const update = () => {
274
- * button.textContent = "count is " + model.get("count");
275
- * };
276
- * button.onclick = () => {
277
- * model.set("count", model.get("count") + 1);
278
- * model.save_changes();
279
- * };
280
- * model.on("change:count", update);
281
- * update();
282
- * el.append(button);
283
- * },
284
- * };`,
285
- * state: { count: 0 },
286
- * });
287
- * counter; // the cell's result: displays the widget
288
- * ```
289
- * ![A JupyterLab cell that creates a counter widget with jupyter.widget. Below the code, the output is a button reading "count is 3" after three clicks.](https://cdp-kernel.org/images/widgets/counter.png)
290
- * @see widgets.md#a-first-widget-a-counter
291
- */
292
- widget(options: WidgetOptions): Widget;
293
- /**
294
- * An `AbortSignal` that an interrupt aborts, so code waiting on something
295
- * only it can end can stop and clean up. Each cell gets its own, as does
296
- * a comm handler that starts while no cell runs. Read it once when the
297
- * work starts: a later read gets the signal of whatever started last.
298
- * The abort reason is a `DOMException` named `AbortError`.
299
- *
300
- * @example
301
- * ```ts
302
- * const response = await fetch("https://example.com/large.csv", { signal: jupyter.signal });
303
- * ```
304
- * @example
305
- * ```ts
306
- * const signal = jupyter.signal;
307
- * const ask = jupyter.comm.open("my.app", { question: "ready?" });
308
- * const answer = await new Promise((resolve, reject) => {
309
- * ask.onMsg((m) => resolve(m.data));
310
- * signal.addEventListener("abort", () => { ask.close(); reject(signal.reason); }, { once: true });
311
- * });
312
- * ```
313
- * @see jupyter-api.md#stopping-on-interrupt-jupyter-signal
314
- */
315
- readonly signal: AbortSignal;
316
- /**
317
- * The parameters of this run: a frozen object, empty unless whoever
318
- * started the kernel passed some, such as `ganymede execute -p
319
- * counter="2nd Ave"`. They arrive as a JSON object in the kernel's
320
- * `GANYMEDE_PARAMS` environment variable. Take defaults with
321
- * destructuring, so the notebook also runs on its own.
210
+ * The MIME bundle that displaying a value would send, without showing
211
+ * anything. For plain values `text/plain` holds terminal color codes,
212
+ * as the notebook shows them.
322
213
  *
323
- * @throws {TypeError} `GANYMEDE_PARAMS` is set but isn't a JSON object.
324
- * @availability Node, Deno, Bun, workerd and celld read the kernel's
325
- * environment; the browser target takes `params` from its options.
214
+ * @param obj The value to format.
215
+ * @returns The bundle, for example `{ "text/plain": "\x1b[33m42\x1b[39m" }`
216
+ * for `42`.
326
217
  * @example
327
218
  * ```ts
328
- * const { counter = "Fremont", days = 30 } = jupyter.params as { counter?: string; days?: number };
329
- * `${counter}, last ${days} days`;
219
+ * const bundle = await jupyter.format(jupyter.md`# Weekly report`);
220
+ * bundle["text/markdown"]; // "# Weekly report"
330
221
  * ```
331
- * @see jupyter-api.md#parameters-and-named-results
222
+ * @see jupyter-api.md#getting-a-bundle-with-format
332
223
  */
333
- readonly params: { readonly [name: string]: JsonValue };
224
+ format(obj: unknown): Promise<MediaBundle>;
334
225
  /**
335
- * Publish a value under a name, as an output that a run's caller reads
336
- * by name instead of parsing outputs: `ganymede execute --results`, or
337
- * another notebook. The output has the MIME type
338
- * `application/vnd.ganymede.result+json` holding `{ name, value }`, and
339
- * a `text/plain` line `name = value` for frontends that don't know the
340
- * type. When a name is published twice, readers take the last one.
226
+ * Send a raw Jupyter message on the kernel's output channel (IOPub), as a
227
+ * reply to the running cell. The other helpers are built on it. It does
228
+ * not check the message: a wrong field is ignored or mishandled by the
229
+ * frontend.
341
230
  *
342
- * @param name The result's name.
343
- * @param value Any value `JSON.stringify` accepts; it is stored as JSON,
344
- * so a `Date` becomes its ISO string.
345
- * @throws {TypeError} `name` is empty, or `value` isn't JSON (a
346
- * function, `undefined`, a `BigInt` or a cycle).
231
+ * @param msgType An IOPub message type, such as `"clear_output"` or
232
+ * `"display_data"`.
233
+ * @param content The message content, as the messaging spec defines it
234
+ * for `msgType`.
235
+ * @param options `metadata` for the message, and binary `buffers`.
347
236
  * @example
348
237
  * ```ts
349
- * const counts = [3120, 2890, 2410];
350
- * jupyter.result("weekday_drop", (counts[2] - counts[0]) / counts[0]);
351
- * jupyter.result("counts", counts);
238
+ * // Count down in place: clear_output with wait avoids a flicker.
239
+ * for (const n of [3, 2, 1]) {
240
+ * jupyter.broadcast("clear_output", { wait: true });
241
+ * await jupyter.display(jupyter.md`Launching in **${n}**…`);
242
+ * await new Promise((resolve) => setTimeout(resolve, 1000));
243
+ * }
352
244
  * ```
353
- * @see jupyter-api.md#parameters-and-named-results
245
+ * @see jupyter-api.md#raw-messages-with-broadcast
354
246
  */
355
- result(name: string, value: unknown): void;
247
+ broadcast(
248
+ msgType: string,
249
+ content: object,
250
+ options?: { metadata?: object; buffers?: Uint8Array[] },
251
+ ): void;
356
252
  /**
357
253
  * Show rows and columns as a table. The output has Ganymede's table
358
254
  * type, `application/vnd.dataresource+json` (Table Schema fields, up to
@@ -456,6 +352,111 @@ export interface JupyterNamespace {
456
352
  * @see jupyter-api.md#charts
457
353
  */
458
354
  chart(chart: object, options?: ChartOptions | Record<string, unknown>): Displayable;
355
+ /**
356
+ * An `AbortSignal` that an interrupt aborts, so code waiting on something
357
+ * only it can end can stop and clean up. Each cell gets its own, as does
358
+ * a comm handler that starts while no cell runs. Read it once when the
359
+ * work starts: a later read gets the signal of whatever started last.
360
+ * The abort reason is a `DOMException` named `AbortError`.
361
+ *
362
+ * @example
363
+ * ```ts
364
+ * const response = await fetch("https://example.com/large.csv", { signal: jupyter.signal });
365
+ * ```
366
+ * @example
367
+ * ```ts
368
+ * const signal = jupyter.signal;
369
+ * const ask = jupyter.comm.open("my.app", { question: "ready?" });
370
+ * const answer = await new Promise((resolve, reject) => {
371
+ * ask.onMsg((m) => resolve(m.data));
372
+ * signal.addEventListener("abort", () => { ask.close(); reject(signal.reason); }, { once: true });
373
+ * });
374
+ * ```
375
+ * @see jupyter-api.md#stopping-on-interrupt-jupyter-signal
376
+ */
377
+ readonly signal: AbortSignal;
378
+ /**
379
+ * Jupyter comms: message channels to the frontend outside cell
380
+ * execution. Its methods are in the widgets and comms reference.
381
+ *
382
+ * @see widgets.md#comms
383
+ */
384
+ comm: CommApi;
385
+ /**
386
+ * Create an [anywidget](https://anywidget.dev)-compatible [widget
387
+ * model](widgets-reference.md#widget-model). Display it (or end the
388
+ * cell with it) to show a view.
389
+ *
390
+ * @param options The view module, its CSS, and the initial state.
391
+ * @returns The model, synced with every view of it.
392
+ * @throws {TypeError} `options.esm` is not a string.
393
+ * @availability The frontend needs the [anywidget](https://anywidget.dev) extension.
394
+ * @example
395
+ * ```ts
396
+ * const counter = jupyter.widget({
397
+ * esm: `export default {
398
+ * render({ model, el }) {
399
+ * const button = document.createElement("button");
400
+ * const update = () => {
401
+ * button.textContent = "count is " + model.get("count");
402
+ * };
403
+ * button.onclick = () => {
404
+ * model.set("count", model.get("count") + 1);
405
+ * model.save_changes();
406
+ * };
407
+ * model.on("change:count", update);
408
+ * update();
409
+ * el.append(button);
410
+ * },
411
+ * };`,
412
+ * state: { count: 0 },
413
+ * });
414
+ * counter; // the cell's result: displays the widget
415
+ * ```
416
+ * ![A JupyterLab cell that creates a counter widget with jupyter.widget. Below the code, the output is a button reading "count is 3" after three clicks.](https://cdp-kernel.org/images/widgets/counter.png)
417
+ * @see widgets.md#a-first-widget-a-counter
418
+ */
419
+ widget(options: WidgetOptions): Widget;
420
+ /**
421
+ * The parameters of this run: a frozen object, empty unless whoever
422
+ * started the kernel passed some, such as `ganymede execute -p
423
+ * counter="2nd Ave"`. They arrive as a JSON object in the kernel's
424
+ * `GANYMEDE_PARAMS` environment variable. Take defaults with
425
+ * destructuring, so the notebook also runs on its own.
426
+ *
427
+ * @throws {TypeError} `GANYMEDE_PARAMS` is set but isn't a JSON object.
428
+ * @availability Node, Deno, Bun, workerd and celld read the kernel's
429
+ * environment; the browser target takes `params` from its options.
430
+ * @example
431
+ * ```ts
432
+ * const { counter = "Fremont", days = 30 } = jupyter.params as { counter?: string; days?: number };
433
+ * `${counter}, last ${days} days`;
434
+ * ```
435
+ * @see jupyter-api.md#parameters-and-named-results
436
+ */
437
+ readonly params: { readonly [name: string]: JsonValue };
438
+ /**
439
+ * Publish a value under a name, as an output that a run's caller reads
440
+ * by name instead of parsing outputs: `ganymede execute --results`, or
441
+ * another notebook. The output has the MIME type
442
+ * `application/vnd.ganymede.result+json` holding `{ name, value }`, and
443
+ * a `text/plain` line `name = value` for frontends that don't know the
444
+ * type. When a name is published twice, readers take the last one.
445
+ *
446
+ * @param name The result's name.
447
+ * @param value Any value `JSON.stringify` accepts; it is stored as JSON,
448
+ * so a `Date` becomes its ISO string.
449
+ * @throws {TypeError} `name` is empty, or `value` isn't JSON (a
450
+ * function, `undefined`, a `BigInt` or a cycle).
451
+ * @example
452
+ * ```ts
453
+ * const counts = [3120, 2890, 2410];
454
+ * jupyter.result("weekday_drop", (counts[2] - counts[0]) / counts[0]);
455
+ * jupyter.result("counts", counts);
456
+ * ```
457
+ * @see jupyter-api.md#parameters-and-named-results
458
+ */
459
+ result(name: string, value: unknown): void;
459
460
  /**
460
461
  * The Ganymede server running this kernel, through its host API: read,
461
462
  * search and (when granted) run other notebooks under its root. The
@@ -23,7 +23,8 @@ import { createHost } from "./host.mjs";
23
23
  import { createWidgetFactory } from "./widget.mjs";
24
24
  import { createSql } from "./sql.mjs";
25
25
  import { createChart } from "./chart.mjs";
26
- import { isTableLike, tableBundle, tableSource } from "./table.mjs";
26
+ import { isTableLike, pageTable, tableBundle, tableSource } from "./table.mjs";
27
+ import { createTablePager } from "./table-paging.mjs";
27
28
 
28
29
  export const $display = Symbol.for("Jupyter.display");
29
30
 
@@ -368,10 +369,14 @@ export function createJupyter(host) {
368
369
  host.send(msgType, content, metadata, buffers);
369
370
  }
370
371
 
372
+ // Set below, once the comms are: a table cut by the cap is kept for
373
+ // paging when it is sent as an output, not when it is formatted.
374
+ let pager;
375
+
371
376
  async function display(obj, options = { raw: false, update: false }) {
372
377
  const bundle = options.raw && isMediaBundle(obj) ? obj : await format(obj);
373
378
  broadcast(options.update ? "update_display_data" : "display_data", {
374
- data: bundle,
379
+ data: pageTable(bundle, pager, options.display_id),
375
380
  metadata: {},
376
381
  transient: options.display_id ? { display_id: options.display_id } : {},
377
382
  });
@@ -382,7 +387,7 @@ export function createJupyter(host) {
382
387
  try {
383
388
  broadcast("execute_result", {
384
389
  execution_count: executionCount,
385
- data: await format(result),
390
+ data: pageTable(await format(result), pager),
386
391
  metadata: {},
387
392
  // nbclient crashes on a null `transient`; Deno learned this too.
388
393
  transient: {},
@@ -457,12 +462,18 @@ export function createJupyter(host) {
457
462
 
458
463
  // jupyter.sql (#477): DuckDB, loaded on the first query; an interrupt
459
464
  // (jupyter.signal) interrupts the query.
460
- const sql = createSql({ importModule: cellImport("jupyter.sql"), signal: () => current.signal });
465
+ const sql = createSql({
466
+ importModule: cellImport("jupyter.sql"),
467
+ mapped: (specifier) => globalThis.__cdpk?.mapped?.(specifier) ?? null,
468
+ signal: () => current.signal,
469
+ });
461
470
 
462
471
  // jupyter.chart (#479): vega, vega-lite or linkedom, loaded on first use.
463
472
  const chart = createChart({ importModule: cellImport("jupyter.chart"), summarizeSpec: summarizeChart });
464
473
 
465
474
  const comms = createComms((msgType, content, metadata, buffers) => host.send(msgType, content, metadata, buffers));
475
+ // Tables cut by the cap page through the kernel over a comm (ganymede#747).
476
+ pager = createTablePager(comms.api);
466
477
 
467
478
  // jupyter.signal (#418): aborted when the kernel is interrupted, so cell
468
479
  // code can stop waiting on what the interrupt can't reach, such as a comm
package/runtime/sql.d.mts CHANGED
@@ -2,6 +2,19 @@ import type { Displayable } from "./display.d.mts";
2
2
 
3
3
  export const NODE_API: string;
4
4
  export const DUCKDB_WASM: string;
5
+ export interface DuckDBBundle {
6
+ mainModule: string;
7
+ mainWorker: string;
8
+ pthreadWorker?: string | null;
9
+ }
10
+ export const DUCKDB_FILES: {
11
+ readonly mvp: { readonly mainModule: string; readonly mainWorker: string };
12
+ readonly eh: { readonly mainModule: string; readonly mainWorker: string };
13
+ };
14
+ export function duckdbBundles(
15
+ jsDelivr: Record<string, DuckDBBundle>,
16
+ mapped?: (specifier: string) => string | null | undefined,
17
+ ): Record<string, DuckDBBundle>;
5
18
 
6
19
  /** One column of a query's result. */
7
20
  export interface SqlColumn {
@@ -142,6 +155,7 @@ export interface SqlEngine {
142
155
 
143
156
  export function createSql(options: {
144
157
  importModule: (specifier: string) => Promise<any>;
158
+ mapped?: (specifier: string) => string | null | undefined;
145
159
  signal?: () => AbortSignal | undefined;
146
160
  openEngine?: () => Promise<SqlEngine>;
147
161
  }): Sql;
package/runtime/sql.mjs CHANGED
@@ -21,6 +21,35 @@ export { timeText, uniqueNames };
21
21
  export const NODE_API = "npm:@duckdb/node-api@1.5.6-r.2";
22
22
  export const DUCKDB_WASM = "npm:@duckdb/duckdb-wasm@1.29.0";
23
23
 
24
+ /**
25
+ * The files duckdb-wasm runs from besides its module, by the specifier a
26
+ * browser kernel's `modules` option maps to a URL the page serves. A
27
+ * variant whose wasm and worker are both mapped loads from those URLs;
28
+ * the others load from jsDelivr, as duckdb-wasm's own list has them.
29
+ */
30
+ export const DUCKDB_FILES = {
31
+ mvp: { mainModule: `${DUCKDB_WASM}/dist/duckdb-mvp.wasm`, mainWorker: `${DUCKDB_WASM}/dist/duckdb-browser-mvp.worker.js` },
32
+ eh: { mainModule: `${DUCKDB_WASM}/dist/duckdb-eh.wasm`, mainWorker: `${DUCKDB_WASM}/dist/duckdb-browser-eh.worker.js` },
33
+ };
34
+
35
+ /**
36
+ * duckdb-wasm's bundles: jsDelivr's, with each variant `mapped` gives
37
+ * both files for in their place.
38
+ *
39
+ * @param {Record<string, { mainModule: string, mainWorker: string, pthreadWorker?: string | null }>} jsDelivr
40
+ * @param {(specifier: string) => string | null | undefined} [mapped]
41
+ */
42
+ export function duckdbBundles(jsDelivr, mapped) {
43
+ const bundles = { ...jsDelivr };
44
+ if (!mapped) return bundles;
45
+ for (const [variant, files] of Object.entries(DUCKDB_FILES)) {
46
+ const mainModule = mapped(files.mainModule);
47
+ const mainWorker = mapped(files.mainWorker);
48
+ if (mainModule && mainWorker) bundles[variant] = { mainModule, mainWorker, pthreadWorker: null };
49
+ }
50
+ return bundles;
51
+ }
52
+
24
53
  const $display = Symbol.for("Jupyter.display");
25
54
  const RAW = Symbol("cdpk.sql.raw");
26
55
 
@@ -190,6 +219,7 @@ const DUCK_TYPES = {
190
219
  /**
191
220
  * @param {{
192
221
  * importModule: (specifier: string) => Promise<any>,
222
+ * mapped?: (specifier: string) => string | null | undefined,
193
223
  * signal?: () => AbortSignal | undefined,
194
224
  * openEngine?: () => Promise<Engine>,
195
225
  * }} options
@@ -201,9 +231,9 @@ const DUCK_TYPES = {
201
231
  * connection: unknown,
202
232
  * }} Engine
203
233
  */
204
- export function createSql({ importModule, signal, openEngine }) {
234
+ export function createSql({ importModule, mapped, signal, openEngine }) {
205
235
  let engine = null;
206
- const open = () => (engine ??= (openEngine ?? (() => defaultEngine(importModule)))().catch((err) => {
236
+ const open = () => (engine ??= (openEngine ?? (() => defaultEngine(importModule, mapped)))().catch((err) => {
207
237
  engine = null;
208
238
  throw err;
209
239
  }));
@@ -321,9 +351,9 @@ function hasNativeRuntime() {
321
351
  typeof globalThis.process?.versions?.node === "string" && !inWorker();
322
352
  }
323
353
 
324
- async function defaultEngine(importModule) {
354
+ async function defaultEngine(importModule, mapped) {
325
355
  if (hasNativeRuntime()) return nativeEngine(importModule);
326
- if (inWorker() && typeof Worker === "function") return wasmEngine(importModule);
356
+ if (inWorker() && typeof Worker === "function") return wasmEngine(importModule, mapped);
327
357
  throw new Error("jupyter.sql needs Node, Deno, Bun or the browser target; workerd and celld have no DuckDB");
328
358
  }
329
359
 
@@ -355,9 +385,10 @@ async function nativeEngine(importModule) {
355
385
  };
356
386
  }
357
387
 
358
- async function wasmEngine(importModule) {
388
+ async function wasmEngine(importModule, mapped) {
359
389
  const duckdb = await importModule(DUCKDB_WASM);
360
- const bundle = await duckdb.selectBundle(duckdb.getJsDelivrBundles());
390
+ // An app that serves the files (DUCKDB_FILES) runs SQL offline.
391
+ const bundle = await duckdb.selectBundle(duckdbBundles(duckdb.getJsDelivrBundles(), mapped));
361
392
  // A Worker can only start from its own origin's scripts, so a Blob
362
393
  // script loads DuckDB's.
363
394
  const loader = URL.createObjectURL(new Blob([`importScripts(${JSON.stringify(bundle.mainWorker)});`], { type: "text/javascript" }));
@@ -0,0 +1,19 @@
1
+ import type { CommApi } from "./comm.d.mts";
2
+ import type { TablePager } from "./table.d.mts";
3
+
4
+ /** The comm target a frontend opens, with `{ id }`, to page through a table. */
5
+ export const PAGE_TARGET: "ganymede.table.v1";
6
+ /** How many tables the kernel keeps for paging, the newest. */
7
+ export const KEEP: number;
8
+ /** The most rows one request returns. */
9
+ export const MAX_PAGE: number;
10
+ /** The most rows a table can have to be sorted in the kernel. */
11
+ export const MAX_SORT: number;
12
+
13
+ /** Compares two encoded values of a column as the Ganymede apps' table does. */
14
+ export function compareEncoded(a: unknown, b: unknown, type: string | undefined, descending?: boolean): number;
15
+
16
+ export function createTablePager(
17
+ commApi: CommApi,
18
+ options?: { keep?: number; newId?: () => string },
19
+ ): TablePager & { size(): number };