@tlockney/cdp-kernel 0.10.0 → 0.11.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,39 @@
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
+ export interface TableBundleOptions {
22
+ limit?: number;
23
+ title?: string;
24
+ columns?: string[];
25
+ }
26
+
27
+ export function isArrowTable(value: unknown): boolean;
28
+ export function isPolarsFrame(value: unknown): boolean;
29
+ export function isDuckDBResult(value: unknown): boolean;
30
+ export function isTableLike(value: unknown): boolean;
31
+ export function schemaType(declared: unknown): string | undefined;
32
+ export function encodeValue(value: unknown, type: string, declared?: string): unknown;
33
+ /** A time of day as `HH:MM:SS[.fraction]`, from a count in the declared type's unit (microseconds by default). */
34
+ export function timeText(value: number | bigint, declared?: string): string;
35
+ /** Column names made unique: a repeated `id` becomes `id_1`, `id_2`, and so on. */
36
+ export function uniqueNames(names: readonly string[]): string[];
37
+ export function tableSource(value: unknown, options?: TableBundleOptions): TableSource;
38
+ export function tableBundle(source: TableSource, options?: TableBundleOptions): { [mediaType: string]: unknown };
39
+ export function fieldsOf(source: TableSource, rows: unknown[][]): { name: string; type: string }[];