@tlockney/cdp-kernel 0.10.0 → 0.12.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.
@@ -0,0 +1,426 @@
1
+ // @ts-self-types="./sql.d.mts"
2
+ // jupyter.sql (cdp-kernel#477, ganymede#747): a tagged template over an
3
+ // in-memory DuckDB, whose results show as table output (table.mjs).
4
+ //
5
+ // const rows = await jupyter.sql`select * from trips where riders > ${min}`;
6
+ //
7
+ // Template values become bound parameters, never query text; sql.raw()
8
+ // is the explicit way to put text in. One database per kernel, opened on
9
+ // the first query, as ganymede-ui's DuckDB example does (ganymede-ui#559):
10
+ // DuckDB's native build, @duckdb/node-api, on Node, Deno and Bun, and its
11
+ // WebAssembly build in a Worker of its own in the browser target (the
12
+ // apps' in-app kernel). The packages load through the cell's own import,
13
+ // so npm: resolution and the browser target's CDN apply as in a cell.
14
+ //
15
+ // No static imports beyond table.mjs: this file must load in every target.
16
+
17
+ import { fieldsOf, tableBundle, tableSource, timeText, uniqueNames } from "./table.mjs";
18
+
19
+ export { timeText, uniqueNames };
20
+
21
+ export const NODE_API = "npm:@duckdb/node-api@1.5.6-r.2";
22
+ export const DUCKDB_WASM = "npm:@duckdb/duckdb-wasm@1.29.0";
23
+
24
+ const $display = Symbol.for("Jupyter.display");
25
+ const RAW = Symbol("cdpk.sql.raw");
26
+
27
+ /** A value from DuckDB as plain JavaScript: safe bigints as numbers. */
28
+ function jsValue(value, declared) {
29
+ if (/^time(?!stamp)/i.test(declared ?? "") && (typeof value === "bigint" || typeof value === "number")) {
30
+ return timeText(value, declared);
31
+ }
32
+ if (typeof value === "bigint") return Number.isSafeInteger(Number(value)) ? Number(value) : value;
33
+ if (typeof value === "number" && /^(date|timestamp)/i.test(declared ?? "")) return new Date(value);
34
+ if (Array.isArray(value)) return value.map((v) => jsValue(v));
35
+ if (value !== null && typeof value === "object" && !(value instanceof Date) && !ArrayBuffer.isView(value)) {
36
+ if (typeof value.toJSON === "function" && !(Symbol.iterator in value) && Object.getPrototypeOf(value) !== Object.prototype) {
37
+ return jsValue(value.toJSON());
38
+ }
39
+ if (typeof value.toArray === "function") return jsValue(Array.from(value.toArray()));
40
+ const out = {};
41
+ for (const [k, v] of Object.entries(value)) out[k] = jsValue(v);
42
+ return out;
43
+ }
44
+ return value;
45
+ }
46
+
47
+ /**
48
+ * An Arrow decimal's little-endian 32-bit words, scaled, as a number; with
49
+ * no scale (HUGEINT), a bigint when a number can't hold it.
50
+ */
51
+ export function decimalValue(words, scale) {
52
+ if (typeof words === "bigint" && scale === 0) return jsValue(words);
53
+ if (typeof words === "number" || typeof words === "bigint") return Number(words) / 10 ** scale;
54
+ const list = Array.from(words);
55
+ let big = 0n;
56
+ list.forEach((w, i) => (big |= BigInt(w >>> 0) << BigInt(32 * i)));
57
+ const bits = BigInt(32 * list.length);
58
+ if (list.length && list.at(-1) >>> 31) big -= 1n << bits;
59
+ return scale === 0 ? jsValue(big) : Number(big) / 10 ** scale;
60
+ }
61
+
62
+ /** A query's result: its columns and rows, shown as a table. */
63
+ export class SqlResult {
64
+ /**
65
+ * @param {{ name: string, type: string }[]} columns
66
+ * @param {Record<string, unknown>[]} rows
67
+ */
68
+ constructor(columns, rows) {
69
+ this.columns = columns;
70
+ this.rows = rows;
71
+ }
72
+ /** table.mjs reads this instead of guessing at the shape. */
73
+ toTableSource(names) {
74
+ const columns = names ? this.columns.filter((c) => names.includes(c.name)) : this.columns;
75
+ return {
76
+ columns: columns.map((c) => ({ name: c.name, declared: c.type })),
77
+ total: this.rows.length,
78
+ read: (start, end) => this.rows.slice(start, end).map((row) => columns.map((c) => row[c.name])),
79
+ };
80
+ }
81
+ [$display]() {
82
+ return tableBundle(tableSource(this));
83
+ }
84
+ }
85
+
86
+ /** A JavaScript value as a DuckDB parameter. */
87
+ export function toParam(value) {
88
+ if (value === undefined) return null;
89
+ if (value instanceof Date) return value.toISOString();
90
+ if (typeof value === "object" && value !== null && !ArrayBuffer.isView(value)) return JSON.stringify(value);
91
+ return value;
92
+ }
93
+
94
+ /** Joins a template's strings with `?` for each value, inlining sql.raw(). */
95
+ export function compose(strings, values) {
96
+ let text = strings[0];
97
+ const params = [];
98
+ values.forEach((value, i) => {
99
+ if (value !== null && typeof value === "object" && RAW in value) text += value[RAW];
100
+ else {
101
+ text += "?";
102
+ params.push(toParam(value));
103
+ }
104
+ text += strings[i + 1];
105
+ });
106
+ return { text, params };
107
+ }
108
+
109
+ export function quoteIdent(name) {
110
+ return `"${String(name).replaceAll('"', '""')}"`;
111
+ }
112
+
113
+ export function quoteString(text) {
114
+ return `'${String(text).replaceAll("'", "''")}'`;
115
+ }
116
+
117
+ /**
118
+ * A parameter (as toParam leaves it) as a SQL literal. A negative number
119
+ * is parenthesized, since after a minus sign `-1` would make `--`, a
120
+ * comment.
121
+ */
122
+ export function sqlLiteral(value) {
123
+ const text = literalText(value);
124
+ return text.startsWith("-") ? `(${text})` : text;
125
+ }
126
+
127
+ function literalText(value) {
128
+ if (value === null || value === undefined) return "NULL";
129
+ if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
130
+ if (typeof value === "bigint") return `${value}`;
131
+ if (typeof value === "number") {
132
+ if (Number.isSafeInteger(value)) return String(value);
133
+ return Number.isFinite(value) ? `${value}::DOUBLE` : `'${value}'::DOUBLE`;
134
+ }
135
+ if (value instanceof Uint8Array) {
136
+ return `'${Array.from(value, (b) => `\\x${b.toString(16).padStart(2, "0")}`).join("")}'::BLOB`;
137
+ }
138
+ if (ArrayBuffer.isView(value)) return `[${Array.from(value, (v) => sqlLiteral(v)).join(", ")}]`;
139
+ return quoteString(value);
140
+ }
141
+
142
+ /**
143
+ * The query with each `?` placeholder replaced by its parameter as a
144
+ * literal. A `?` inside a string, a quoted name or a comment is left as it
145
+ * is. duckdb-wasm can cancel only an unprepared query, so it runs this.
146
+ * Dollar-quoted (`$$…$$`) and `E'…'` strings and nested comments aren't
147
+ * recognized: a `?` in one counts as a placeholder, and the count then
148
+ * fails to match the parameters and throws.
149
+ */
150
+ export function inlineParams(text, params) {
151
+ if (params.length === 0) return text;
152
+ let out = "";
153
+ let next = 0;
154
+ for (let i = 0; i < text.length; i++) {
155
+ const c = text[i];
156
+ const end =
157
+ c === "'" || c === '"' ? text.indexOf(c, i + 1) :
158
+ c === "-" && text[i + 1] === "-" ? text.indexOf("\n", i) :
159
+ c === "/" && text[i + 1] === "*" ? text.indexOf("*/", i + 2) + 1 :
160
+ undefined;
161
+ if (end !== undefined) {
162
+ // A doubled quote ('it''s') reads as two strings back to back.
163
+ const stop = end < 0 || end === 0 ? text.length : end + 1;
164
+ out += text.slice(i, stop);
165
+ i = stop - 1;
166
+ } else if (c === "?") {
167
+ if (next >= params.length) throw new RangeError("jupyter.sql: more ? placeholders than parameters");
168
+ // A space after a quote, so 'a'? doesn't read as one string 'a''x'.
169
+ if (out.endsWith("'") || out.endsWith('"')) out += " ";
170
+ out += sqlLiteral(params[next++]);
171
+ } else out += c;
172
+ }
173
+ if (next < params.length) throw new RangeError("jupyter.sql: more parameters than ? placeholders");
174
+ return out;
175
+ }
176
+
177
+ const DUCK_TYPES = {
178
+ integer: "BIGINT",
179
+ number: "DOUBLE",
180
+ boolean: "BOOLEAN",
181
+ string: "VARCHAR",
182
+ date: "DATE",
183
+ datetime: "TIMESTAMP",
184
+ time: "TIME",
185
+ array: "JSON",
186
+ object: "JSON",
187
+ duration: "VARCHAR",
188
+ };
189
+
190
+ /**
191
+ * @param {{
192
+ * importModule: (specifier: string) => Promise<any>,
193
+ * signal?: () => AbortSignal | undefined,
194
+ * openEngine?: () => Promise<Engine>,
195
+ * }} options
196
+ *
197
+ * @typedef {{
198
+ * query: (text: string, params: unknown[]) => Promise<SqlResult>,
199
+ * interrupt?: () => void,
200
+ * close: () => Promise<void>,
201
+ * connection: unknown,
202
+ * }} Engine
203
+ */
204
+ export function createSql({ importModule, signal, openEngine }) {
205
+ let engine = null;
206
+ const open = () => (engine ??= (openEngine ?? (() => defaultEngine(importModule)))().catch((err) => {
207
+ engine = null;
208
+ throw err;
209
+ }));
210
+
211
+ async function run(text, params) {
212
+ // The cell's signal, read before the first open's wait, so a cell that
213
+ // starts meanwhile doesn't take the query over.
214
+ const s = signal?.();
215
+ const e = await open();
216
+ if (s?.aborted) throw s.reason;
217
+ const onAbort = () => e.interrupt?.();
218
+ s?.addEventListener("abort", onAbort, { once: true });
219
+ try {
220
+ return await e.query(text, params);
221
+ } finally {
222
+ s?.removeEventListener("abort", onAbort);
223
+ }
224
+ }
225
+
226
+ function sql(strings, ...values) {
227
+ if (!Array.isArray(strings) || !Array.isArray(strings.raw)) {
228
+ return Promise.reject(new TypeError("jupyter.sql is a tag: jupyter.sql`select ...`; for a string, use jupyter.sql.query(text, params)"));
229
+ }
230
+ const { text, params } = compose(strings, values);
231
+ return run(text, params);
232
+ }
233
+
234
+ sql.query = (text, params = []) => {
235
+ if (typeof text !== "string") return Promise.reject(new TypeError("jupyter.sql.query: text must be a string"));
236
+ return run(text, params.map(toParam));
237
+ };
238
+
239
+ sql.raw = (text) => ({ [RAW]: String(text) });
240
+
241
+ sql.load = async (name, source) => {
242
+ if (typeof name !== "string" || name === "") throw new TypeError("jupyter.sql.load: name must be a non-empty string");
243
+ const table = quoteIdent(name);
244
+ if (typeof source === "string") {
245
+ await run(`create or replace table ${table} as select * from ${quoteString(source)}`, []);
246
+ return;
247
+ }
248
+ const { structure, json } = rowsAsJson(source);
249
+ if (structure === null) {
250
+ throw new TypeError("jupyter.sql.load: the rows have no columns");
251
+ }
252
+ await run(
253
+ `create or replace table ${table} as select unnest(from_json(?, ?), recursive := true)`,
254
+ [json, structure],
255
+ );
256
+ };
257
+
258
+ sql.close = async () => {
259
+ const e = engine;
260
+ engine = null;
261
+ if (e) await (await e).close();
262
+ };
263
+
264
+ sql.connection = async () => (await open()).connection;
265
+
266
+ return sql;
267
+ }
268
+
269
+ /** A value as JSON for loading: nothing clipped, unlike a table's display. */
270
+ function loadValue(value, type, depth = 0) {
271
+ if (value === null || value === undefined) return null;
272
+ if (type === "date" || type === "datetime") {
273
+ const d = value instanceof Date ? value : typeof value === "number" || typeof value === "bigint" ? new Date(Number(value)) : null;
274
+ if (d) return Number.isNaN(d.getTime()) ? null : type === "date" ? d.toISOString().slice(0, 10) : d.toISOString();
275
+ }
276
+ if (typeof value === "bigint") return Number.isSafeInteger(Number(value)) ? Number(value) : String(value);
277
+ // DOUBLE takes no "NaN" text from JSON; null instead.
278
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
279
+ if (typeof value !== "object") return value;
280
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString();
281
+ if (depth > 64) throw new TypeError("jupyter.sql.load: a value is nested more than 64 levels deep");
282
+ if (ArrayBuffer.isView(value) && !(value instanceof DataView) && value.BYTES_PER_ELEMENT === 1) {
283
+ // Bytes as base64 text: JSON has no binary.
284
+ let text = "";
285
+ for (const byte of value) text += String.fromCharCode(byte);
286
+ return btoa(text);
287
+ }
288
+ if (Array.isArray(value) || ArrayBuffer.isView(value)) return Array.from(value, (v) => loadValue(v, undefined, depth + 1));
289
+ if (typeof value.toJSON === "function" && Object.getPrototypeOf(value) !== Object.prototype) {
290
+ return loadValue(value.toJSON(), undefined, depth + 1);
291
+ }
292
+ const out = {};
293
+ for (const [k, v] of Object.entries(value)) out[k] = loadValue(v, undefined, depth + 1);
294
+ return out;
295
+ }
296
+
297
+ /** Rows, an Arrow table or a DataFrame as JSON and a from_json structure. */
298
+ export function rowsAsJson(source) {
299
+ const src = tableSource(source);
300
+ const raw = src.read(0, src.total);
301
+ const fields = fieldsOf(src, raw);
302
+ if (fields.length === 0) return { structure: null, json: "[]" };
303
+ const rows = raw.map((r) => {
304
+ const obj = {};
305
+ fields.forEach((f, i) => {
306
+ obj[f.name] = loadValue(r[i], f.type);
307
+ });
308
+ return obj;
309
+ });
310
+ const structure = {};
311
+ for (const f of fields) structure[f.name] = DUCK_TYPES[f.type] ?? "VARCHAR";
312
+ return { structure: JSON.stringify([structure]), json: JSON.stringify(rows) };
313
+ }
314
+
315
+ function inWorker() {
316
+ return typeof globalThis.WorkerGlobalScope === "function" && globalThis instanceof globalThis.WorkerGlobalScope;
317
+ }
318
+
319
+ function hasNativeRuntime() {
320
+ return typeof globalThis.Deno?.version?.deno === "string" || typeof globalThis.Bun?.version === "string" ||
321
+ typeof globalThis.process?.versions?.node === "string" && !inWorker();
322
+ }
323
+
324
+ async function defaultEngine(importModule) {
325
+ if (hasNativeRuntime()) return nativeEngine(importModule);
326
+ if (inWorker() && typeof Worker === "function") return wasmEngine(importModule);
327
+ throw new Error("jupyter.sql needs Node, Deno, Bun or the browser target; workerd and celld have no DuckDB");
328
+ }
329
+
330
+ async function nativeEngine(importModule) {
331
+ const { DuckDBInstance, blobValue } = await importModule(NODE_API);
332
+ const db = await DuckDBInstance.create(":memory:");
333
+ const conn = await db.connect();
334
+ return {
335
+ connection: conn,
336
+ interrupt: () => conn.interrupt?.(),
337
+ async query(text, params) {
338
+ // Bytes need a declared BLOB; the rest bind by their JavaScript type.
339
+ const bound = params.map((p) => (p instanceof Uint8Array && blobValue ? blobValue(p) : p));
340
+ const result = await conn.runAndReadAll(text, bound);
341
+ const types = result.columnTypes();
342
+ const columns = uniqueNames(result.columnNames()).map((name, i) => ({ name, type: String(types[i]) }));
343
+ // By position, as two columns may share a name.
344
+ const rows = result.getRowsJS().map((row) => {
345
+ const out = {};
346
+ columns.forEach((c, j) => (out[c.name] = jsValue(row[j], c.type)));
347
+ return out;
348
+ });
349
+ return new SqlResult(columns, rows);
350
+ },
351
+ async close() {
352
+ conn.closeSync?.();
353
+ db.closeSync?.();
354
+ },
355
+ };
356
+ }
357
+
358
+ async function wasmEngine(importModule) {
359
+ const duckdb = await importModule(DUCKDB_WASM);
360
+ const bundle = await duckdb.selectBundle(duckdb.getJsDelivrBundles());
361
+ // A Worker can only start from its own origin's scripts, so a Blob
362
+ // script loads DuckDB's.
363
+ const loader = URL.createObjectURL(new Blob([`importScripts(${JSON.stringify(bundle.mainWorker)});`], { type: "text/javascript" }));
364
+ const worker = new Worker(loader);
365
+ const db = new duckdb.AsyncDuckDB(new duckdb.VoidLogger(), worker);
366
+ await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
367
+ URL.revokeObjectURL(loader);
368
+ const conn = await db.connect();
369
+ let cancelled = false;
370
+ return {
371
+ connection: conn,
372
+ interrupt: () => {
373
+ cancelled = true;
374
+ void conn.cancelSent?.();
375
+ },
376
+ async query(text, params) {
377
+ // send() streams the result, which cancelSent() can stop; query()
378
+ // runs to the end in the worker whatever is asked of it, and so
379
+ // does a prepared statement's send(). So the parameters go into the
380
+ // text as literals, and the batch loop stops on an interrupt too.
381
+ cancelled = false;
382
+ const reader = await conn.send(inlineParams(text, params), true);
383
+ const stop = () => {
384
+ if (cancelled) throw new DOMException("The query was interrupted", "AbortError");
385
+ };
386
+ try {
387
+ stop();
388
+ const columns = [];
389
+ const rows = [];
390
+ let decimal = [];
391
+ for await (const batch of reader) {
392
+ if (columns.length === 0 && batch.schema.fields.length) {
393
+ const fields = batch.schema.fields;
394
+ uniqueNames(fields.map((f) => f.name)).forEach((name, j) => columns.push({ name, type: String(fields[j].type) }));
395
+ // Arrow gives a DECIMAL as its 128-bit words; DuckDB's native
396
+ // build gives a number, so this does too.
397
+ decimal = fields.map((f) => (/^decimal/i.test(String(f.type)) ? f.type.scale ?? 0 : null));
398
+ }
399
+ stop();
400
+ const vectors = columns.map((_, j) => batch.getChildAt(j));
401
+ for (let i = 0; i < batch.numRows; i++) {
402
+ const row = {};
403
+ columns.forEach((c, j) => {
404
+ const v = vectors[j]?.get(i);
405
+ row[c.name] = decimal[j] !== null && v != null ? decimalValue(v, decimal[j]) : jsValue(v, c.type);
406
+ });
407
+ rows.push(row);
408
+ }
409
+ }
410
+ if (columns.length === 0 && reader.schema?.fields) {
411
+ const fields = reader.schema.fields;
412
+ uniqueNames(fields.map((f) => f.name)).forEach((name, j) => columns.push({ name, type: String(fields[j].type) }));
413
+ }
414
+ return new SqlResult(columns, rows);
415
+ } catch (err) {
416
+ await Promise.resolve((reader.cancel ?? reader.return)?.call(reader)).catch(() => {});
417
+ throw err;
418
+ }
419
+ },
420
+ async close() {
421
+ await conn.close();
422
+ await db.terminate();
423
+ worker.terminate();
424
+ },
425
+ };
426
+ }
@@ -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 };
@@ -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
+ }
@@ -0,0 +1,53 @@
1
+ /** The MIME type of a table output: Table Schema JSON with rows. */
2
+ export const TABLE_MIME: "application/vnd.dataresource+json";
3
+ export const DEFAULT_LIMIT: number;
4
+ export const MAX_LIMIT: number;
5
+ export const MAX_BYTES: number;
6
+ export const MAX_CELL: number;
7
+
8
+ export interface TableColumn {
9
+ name: string;
10
+ /** The source's own type name, such as `Int64` or `VARCHAR`. */
11
+ declared?: string;
12
+ }
13
+
14
+ /** Columns and random access to rows, which `jupyter.table` reads from. */
15
+ export interface TableSource {
16
+ columns: TableColumn[];
17
+ total: number;
18
+ read: (start: number, end: number) => unknown[][];
19
+ }
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
+
33
+ export interface TableBundleOptions {
34
+ limit?: number;
35
+ title?: string;
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;
39
+ }
40
+
41
+ export function isArrowTable(value: unknown): boolean;
42
+ export function isPolarsFrame(value: unknown): boolean;
43
+ export function isDuckDBResult(value: unknown): boolean;
44
+ export function isTableLike(value: unknown): boolean;
45
+ export function schemaType(declared: unknown): string | undefined;
46
+ export function encodeValue(value: unknown, type: string, declared?: string): unknown;
47
+ /** A time of day as `HH:MM:SS[.fraction]`, from a count in the declared type's unit (microseconds by default). */
48
+ export function timeText(value: number | bigint, declared?: string): string;
49
+ /** Column names made unique: a repeated `id` becomes `id_1`, `id_2`, and so on. */
50
+ export function uniqueNames(names: readonly string[]): string[];
51
+ export function tableSource(value: unknown, options?: TableBundleOptions): TableSource;
52
+ export function tableBundle(source: TableSource, options?: TableBundleOptions): { [mediaType: string]: unknown };
53
+ export function fieldsOf(source: TableSource, rows: unknown[][]): { name: string; type: string }[];