@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/browser/kernel.js +16 -2
- package/dist/browser/kernel.js.map +2 -2
- package/dist/browser/mod.d.ts +27 -0
- package/dist/browser/mod.js +27 -0
- package/dist/browser/target.d.ts +10 -0
- package/dist/browser/target.js +1 -1
- package/dist/browser/worker.js +182 -14
- package/dist/browser/worker.js.map +4 -4
- package/dist/package.d.ts +3 -3
- package/dist/package.js +3 -2
- package/dist/workerd-target.js +1 -1
- package/package.json +1 -1
- package/runtime/browser-worker.mjs +9 -3
- package/runtime/cdn.d.mts +6 -1
- package/runtime/cdn.mjs +7 -2
- package/runtime/display.d.mts +136 -135
- package/runtime/display.mjs +15 -4
- package/runtime/sql.d.mts +14 -0
- package/runtime/sql.mjs +37 -6
- package/runtime/table-paging.d.mts +19 -0
- package/runtime/table-paging.mjs +183 -0
- package/runtime/table.d.mts +14 -0
- package/runtime/table.mjs +31 -2
- package/runtime/widget.d.mts +3 -3
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// @ts-self-types="./table-paging.d.mts"
|
|
2
|
+
// Paging a table through the kernel (ganymede#747): a table output embeds
|
|
3
|
+
// at most `limit` rows; past that, the frontend asks the kernel for the
|
|
4
|
+
// rest over a comm, a page at a time, sorted by a column if it likes.
|
|
5
|
+
//
|
|
6
|
+
// A table cut by the cap is kept here under an id, which the output names
|
|
7
|
+
// as `ganymede.page`. The frontend opens a comm to PAGE_TARGET with
|
|
8
|
+
// `{ id }` and sends `{ request, offset, limit, sort? }`; the kernel answers
|
|
9
|
+
// `{ request, offset, total, rows }`, rows as arrays in field order and
|
|
10
|
+
// encoded as the embedded rows are, or `{ request, error }`. An id the
|
|
11
|
+
// kernel no longer has (it restarted, or the table was evicted) gets
|
|
12
|
+
// `{ error }` and the comm closes, and the frontend keeps to the rows the
|
|
13
|
+
// output embeds.
|
|
14
|
+
//
|
|
15
|
+
// The kernel keeps the last KEEP tables shown, so a notebook that draws
|
|
16
|
+
// many doesn't hold every one; an update of a display keeps its id. Each
|
|
17
|
+
// request looks its table up again, so an open comm doesn't keep an
|
|
18
|
+
// evicted table alive. Sorting reads the whole column, so it is offered up
|
|
19
|
+
// to MAX_SORT rows; the last SORTS orders are kept, so flipping a column's
|
|
20
|
+
// direction doesn't sort again.
|
|
21
|
+
//
|
|
22
|
+
// A table is the live value, not a copy: change it in a later cell and
|
|
23
|
+
// its pages show the change (run the cell again to match).
|
|
24
|
+
|
|
25
|
+
import { encodeRow, encodeValue } from "./table.mjs";
|
|
26
|
+
|
|
27
|
+
export const PAGE_TARGET = "ganymede.table.v1";
|
|
28
|
+
export const KEEP = 16;
|
|
29
|
+
export const MAX_PAGE = 1000;
|
|
30
|
+
export const MAX_SORT = 1_000_000;
|
|
31
|
+
const CHUNK = 10_000;
|
|
32
|
+
const SORTS = 2;
|
|
33
|
+
|
|
34
|
+
const NUMERIC = new Set(["integer", "number"]);
|
|
35
|
+
const INTEGER_TEXT = /^-?\d+$/;
|
|
36
|
+
|
|
37
|
+
function sortNumber(value) {
|
|
38
|
+
if (typeof value === "number") return value;
|
|
39
|
+
if (typeof value === "string" && value.trim() !== "") return Number(value);
|
|
40
|
+
return Number.NaN;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function textOf(value) {
|
|
44
|
+
if (value === null || value === undefined) return "null";
|
|
45
|
+
if (typeof value === "string") return value;
|
|
46
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
47
|
+
return JSON.stringify(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const collator = new Intl.Collator(undefined, { numeric: true });
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compares two encoded values of a column, as the Ganymede apps' table does:
|
|
54
|
+
* nulls, and values that aren't numbers in a numeric column (such as
|
|
55
|
+
* "NaN"), last in either direction; integers past 2^53, sent as strings,
|
|
56
|
+
* exactly.
|
|
57
|
+
*/
|
|
58
|
+
export function compareEncoded(a, b, type, descending = false) {
|
|
59
|
+
const numeric = NUMERIC.has(type) || (typeof a === "number" && typeof b === "number");
|
|
60
|
+
const last = (v) => v === null || v === undefined || (numeric && Number.isNaN(sortNumber(v)));
|
|
61
|
+
const aLast = last(a);
|
|
62
|
+
const bLast = last(b);
|
|
63
|
+
if (aLast || bLast) return aLast === bLast ? 0 : aLast ? 1 : -1;
|
|
64
|
+
const sign = descending ? -1 : 1;
|
|
65
|
+
if (numeric) {
|
|
66
|
+
if (typeof a === "string" && typeof b === "string" && INTEGER_TEXT.test(a) && INTEGER_TEXT.test(b)) {
|
|
67
|
+
const x = BigInt(a);
|
|
68
|
+
const y = BigInt(b);
|
|
69
|
+
return sign * (x < y ? -1 : x > y ? 1 : 0);
|
|
70
|
+
}
|
|
71
|
+
return sign * (sortNumber(a) - sortNumber(b));
|
|
72
|
+
}
|
|
73
|
+
if (typeof a === "boolean" && typeof b === "boolean") return sign * (Number(a) - Number(b));
|
|
74
|
+
return sign * collator.compare(textOf(a), textOf(b));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Keeps tables for paging and answers the frontend's comm.
|
|
79
|
+
* @param {import("./comm.d.mts").CommApi} commApi
|
|
80
|
+
* @param {{ keep?: number, newId?: () => string }} [options]
|
|
81
|
+
*/
|
|
82
|
+
export function createTablePager(commApi, { keep = KEEP, newId = () => crypto.randomUUID() } = {}) {
|
|
83
|
+
/** id -> { source, fields, key?, orders: Map<sortKey, index> } */
|
|
84
|
+
const tables = new Map();
|
|
85
|
+
/** display id -> table id */
|
|
86
|
+
const keyed = new Map();
|
|
87
|
+
|
|
88
|
+
function register(source, fields, key) {
|
|
89
|
+
let id = key === undefined ? undefined : keyed.get(key);
|
|
90
|
+
if (id === undefined || !tables.has(id)) id = newId();
|
|
91
|
+
if (key !== undefined) keyed.set(key, id);
|
|
92
|
+
tables.delete(id);
|
|
93
|
+
tables.set(id, { source, fields, key, orders: new Map() });
|
|
94
|
+
while (tables.size > keep) {
|
|
95
|
+
const [oldest, entry] = tables.entries().next().value;
|
|
96
|
+
tables.delete(oldest);
|
|
97
|
+
if (entry.key !== undefined && keyed.get(entry.key) === oldest) keyed.delete(entry.key);
|
|
98
|
+
}
|
|
99
|
+
return id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function encodeRows(entry, raw) {
|
|
103
|
+
return raw.map((r) => encodeRow(entry.source, entry.fields, r));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Row indexes in sorted order, kept for the last SORTS sorts asked for. */
|
|
107
|
+
function orderFor(entry, column, descending) {
|
|
108
|
+
const key = `${column}\u0000${descending}`;
|
|
109
|
+
const kept = entry.orders.get(key);
|
|
110
|
+
if (kept) return kept;
|
|
111
|
+
const { source, fields } = entry;
|
|
112
|
+
const c = fields.findIndex((f) => f.name === column);
|
|
113
|
+
if (c < 0) throw new Error(`no column ${JSON.stringify(column)}`);
|
|
114
|
+
if (source.total > MAX_SORT) throw new Error(`sorting is offered up to ${MAX_SORT.toLocaleString("en-US")} rows`);
|
|
115
|
+
const type = fields[c].type;
|
|
116
|
+
const declared = source.columns[c].declared;
|
|
117
|
+
const values = new Array(source.total);
|
|
118
|
+
for (let start = 0; start < source.total; start += CHUNK) {
|
|
119
|
+
const raw = source.read(start, Math.min(start + CHUNK, source.total));
|
|
120
|
+
raw.forEach((r, i) => (values[start + i] = encodeValue(r[c], type, declared)));
|
|
121
|
+
}
|
|
122
|
+
const index = Array.from({ length: source.total }, (_, i) => i);
|
|
123
|
+
// Array sort is stable: ties keep the source's order.
|
|
124
|
+
index.sort((i, j) => compareEncoded(values[i], values[j], type, descending));
|
|
125
|
+
entry.orders.set(key, index);
|
|
126
|
+
while (entry.orders.size > SORTS) entry.orders.delete(entry.orders.keys().next().value);
|
|
127
|
+
return index;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function page(entry, { offset, limit, sort }) {
|
|
131
|
+
const { source } = entry;
|
|
132
|
+
const start = Math.max(0, Math.min(offset, source.total));
|
|
133
|
+
const end = Math.min(source.total, start + Math.min(limit, MAX_PAGE));
|
|
134
|
+
if (!sort) return encodeRows(entry, source.read(start, end));
|
|
135
|
+
const index = orderFor(entry, String(sort.column), sort.descending === true).slice(start, end);
|
|
136
|
+
// Sorted rows are scattered in the source: read each run of
|
|
137
|
+
// neighbouring rows at once (a run is the whole page when the sort
|
|
138
|
+
// keeps the source's order).
|
|
139
|
+
const rows = [];
|
|
140
|
+
for (let i = 0; i < index.length; ) {
|
|
141
|
+
let j = i + 1;
|
|
142
|
+
while (j < index.length && index[j] === index[j - 1] + 1) j++;
|
|
143
|
+
rows.push(...source.read(index[i], index[j - 1] + 1));
|
|
144
|
+
i = j;
|
|
145
|
+
}
|
|
146
|
+
return encodeRows(entry, rows);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const gone = "The kernel no longer has this table: run the cell again to page through it.";
|
|
150
|
+
|
|
151
|
+
commApi.registerTarget(PAGE_TARGET, (comm, msg) => {
|
|
152
|
+
const id = msg.data?.id;
|
|
153
|
+
if (typeof id !== "string" || !tables.has(id)) {
|
|
154
|
+
comm.send({ error: gone });
|
|
155
|
+
comm.close();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
comm.onMsg(({ data }) => {
|
|
159
|
+
const request = data?.request;
|
|
160
|
+
// Looked up each time: the comm holds the id, not the table.
|
|
161
|
+
const entry = tables.get(id);
|
|
162
|
+
if (!entry) {
|
|
163
|
+
comm.send({ request, error: gone });
|
|
164
|
+
comm.close();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const offset = data?.offset;
|
|
168
|
+
const limit = data?.limit ?? MAX_PAGE;
|
|
169
|
+
if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1) {
|
|
170
|
+
comm.send({ request, error: "offset and limit must be whole numbers" });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const rows = page(entry, { offset, limit, sort: data?.sort ?? undefined });
|
|
175
|
+
comm.send({ request, offset, total: entry.source.total, rows });
|
|
176
|
+
} catch (err) {
|
|
177
|
+
comm.send({ request, error: err instanceof Error ? err.message : String(err) });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return { register, size: () => tables.size };
|
|
183
|
+
}
|
package/runtime/table.d.mts
CHANGED
|
@@ -18,10 +18,24 @@ export interface TableSource {
|
|
|
18
18
|
read: (start: number, end: number) => unknown[][];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Keeps a table cut by the cap for paging through the kernel; returns its id. */
|
|
22
|
+
export interface TablePager {
|
|
23
|
+
/** `key` (a display id) keeps one id for every table registered under it. */
|
|
24
|
+
register(source: TableSource, fields: { name: string; type: string }[], key?: string): string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Keeps a cut table in `bundle` for paging and names it as `ganymede.page`; returns `bundle`. */
|
|
28
|
+
export function pageTable<T>(bundle: T, pager: TablePager | null | undefined, key?: string): T;
|
|
29
|
+
|
|
30
|
+
/** A row as a table output encodes it. */
|
|
31
|
+
export function encodeRow(source: TableSource, fields: { name: string; type: string }[], row: unknown[]): unknown[];
|
|
32
|
+
|
|
21
33
|
export interface TableBundleOptions {
|
|
22
34
|
limit?: number;
|
|
23
35
|
title?: string;
|
|
24
36
|
columns?: string[];
|
|
37
|
+
/** Keeps the table now if the cap cuts it; otherwise `pageTable` keeps it when it is shown. */
|
|
38
|
+
pager?: TablePager | null;
|
|
25
39
|
}
|
|
26
40
|
|
|
27
41
|
export function isArrowTable(value: unknown): boolean;
|
package/runtime/table.mjs
CHANGED
|
@@ -387,9 +387,33 @@ function textTable(fields, numbered) {
|
|
|
387
387
|
return cells.map((row) => ` ${row.map((c, i) => c.padEnd(widths[i])).join(" | ").trimEnd()}`);
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
/** A cut table's source and fields, on its resource, for `pageTable` to register when the table is shown. */
|
|
391
|
+
const PAGEABLE = Symbol("cdpk.table.pageable");
|
|
392
|
+
|
|
393
|
+
/** A row as the output encodes it: one value a field, in field order. */
|
|
394
|
+
export function encodeRow(source, fields, row) {
|
|
395
|
+
return fields.map((f, i) => encodeValue(row[i], f.type, source.columns[i].declared));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Keeps a cut table in `bundle` for paging (table-paging.mjs) and names it
|
|
400
|
+
* as `ganymede.page`. The jupyter namespace calls this as it sends an
|
|
401
|
+
* output, so a table that is built but never shown (the debugger's
|
|
402
|
+
* variable viewer, an unshown `jupyter.table()`) takes no slot. `key` (a
|
|
403
|
+
* display id) keeps one id for every update of a display.
|
|
404
|
+
*/
|
|
405
|
+
export function pageTable(bundle, pager, key) {
|
|
406
|
+
const resource = bundle?.[TABLE_MIME];
|
|
407
|
+
const pending = resource?.[PAGEABLE];
|
|
408
|
+
if (!pending || !pager || resource.ganymede?.page !== undefined) return bundle;
|
|
409
|
+
resource.ganymede.page = pager.register(pending.source, pending.fields, key);
|
|
410
|
+
return bundle;
|
|
411
|
+
}
|
|
412
|
+
|
|
390
413
|
/**
|
|
391
414
|
* The MIME bundle for a table: Table Schema JSON with up to `limit` rows,
|
|
392
|
-
* an HTML table and a text summary.
|
|
415
|
+
* an HTML table and a text summary. A table cut by the cap can be kept
|
|
416
|
+
* for paging: by `options.pager` now, or by `pageTable` when it is shown.
|
|
393
417
|
*/
|
|
394
418
|
export function tableBundle(source, options = {}) {
|
|
395
419
|
const requested = options.limit ?? DEFAULT_LIMIT;
|
|
@@ -400,7 +424,7 @@ export function tableBundle(source, options = {}) {
|
|
|
400
424
|
|
|
401
425
|
const raw = source.read(0, Math.min(limit, total));
|
|
402
426
|
const fields = fieldsOf(source, raw);
|
|
403
|
-
const encode = (r) =>
|
|
427
|
+
const encode = (r) => encodeRow(source, fields, r);
|
|
404
428
|
const utf8 = new TextEncoder();
|
|
405
429
|
|
|
406
430
|
// Rows as JSON, stopping at MAX_BYTES.
|
|
@@ -458,6 +482,11 @@ export function tableBundle(source, options = {}) {
|
|
|
458
482
|
/** @type {Record<string, unknown>} */
|
|
459
483
|
const resource = { schema: { fields }, data, ganymede: { rows: total, shown } };
|
|
460
484
|
if (title) resource.ganymede.title = title;
|
|
485
|
+
if (shown < total) {
|
|
486
|
+
// Not enumerable, so it never reaches the message.
|
|
487
|
+
Object.defineProperty(resource, PAGEABLE, { value: { source, fields } });
|
|
488
|
+
if (options.pager) pageTable({ [TABLE_MIME]: resource }, options.pager);
|
|
489
|
+
}
|
|
461
490
|
return {
|
|
462
491
|
[TABLE_MIME]: resource,
|
|
463
492
|
"text/html": htmlTable(fields, htmlHead, htmlTail, total, title),
|
package/runtime/widget.d.mts
CHANGED
|
@@ -8,7 +8,7 @@ export type WidgetState = { [key: string]: unknown };
|
|
|
8
8
|
/** Options for `jupyter.widget`. */
|
|
9
9
|
export interface WidgetOptions {
|
|
10
10
|
/**
|
|
11
|
-
* The anywidget view module, as source:
|
|
11
|
+
* The [anywidget](https://anywidget.dev) view module, as source:
|
|
12
12
|
* `export default { render({ model, el }) { ... } }`. A `${...}` inside
|
|
13
13
|
* a template literal here is filled in by the kernel, not the browser:
|
|
14
14
|
* escape it as `\${...}`.
|
|
@@ -24,7 +24,7 @@ export interface WidgetOptions {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* An anywidget model, from `jupyter.widget`, synced with its frontend
|
|
27
|
+
* An [anywidget](https://anywidget.dev) model, from `jupyter.widget`, synced with its frontend
|
|
28
28
|
* views. Display it to show a view; display it again for a second view of
|
|
29
29
|
* the same model.
|
|
30
30
|
*/
|
|
@@ -219,7 +219,7 @@ export interface Widget {
|
|
|
219
219
|
*/
|
|
220
220
|
close(): void;
|
|
221
221
|
/**
|
|
222
|
-
* The full state, as the frontend sees it: anywidget's model keys
|
|
222
|
+
* The full state, as the frontend sees it: [anywidget](https://anywidget.dev)'s model keys
|
|
223
223
|
* (`_model_name`, `_view_name` and the rest) and the widget's own.
|
|
224
224
|
*
|
|
225
225
|
* @returns Every state key and its value.
|