@cyanheads/mcp-ts-core 0.8.11 → 0.8.12
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/CLAUDE.md +2 -1
- package/README.md +1 -1
- package/changelog/0.8.x/0.8.12.md +31 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +6 -1
- package/dist/config/index.js.map +1 -1
- package/dist/services/canvas/core/CanvasInstance.d.ts +3 -4
- package/dist/services/canvas/core/CanvasInstance.d.ts.map +1 -1
- package/dist/services/canvas/core/CanvasInstance.js +3 -4
- package/dist/services/canvas/core/CanvasInstance.js.map +1 -1
- package/dist/services/canvas/core/CanvasRegistry.d.ts +5 -11
- package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
- package/dist/services/canvas/core/CanvasRegistry.js +5 -13
- package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
- package/dist/services/canvas/core/DataCanvas.d.ts +2 -3
- package/dist/services/canvas/core/DataCanvas.d.ts.map +1 -1
- package/dist/services/canvas/core/DataCanvas.js +3 -8
- package/dist/services/canvas/core/DataCanvas.js.map +1 -1
- package/dist/services/canvas/core/IDataCanvasProvider.d.ts +4 -4
- package/dist/services/canvas/core/IDataCanvasProvider.js +4 -4
- package/dist/services/canvas/core/canvasFactory.d.ts +7 -15
- package/dist/services/canvas/core/canvasFactory.d.ts.map +1 -1
- package/dist/services/canvas/core/canvasFactory.js +7 -16
- package/dist/services/canvas/core/canvasFactory.js.map +1 -1
- package/dist/services/canvas/core/sqlGate.d.ts +81 -57
- package/dist/services/canvas/core/sqlGate.d.ts.map +1 -1
- package/dist/services/canvas/core/sqlGate.js +199 -80
- package/dist/services/canvas/core/sqlGate.js.map +1 -1
- package/dist/services/canvas/index.d.ts +1 -1
- package/dist/services/canvas/index.d.ts.map +1 -1
- package/dist/services/canvas/index.js +1 -1
- package/dist/services/canvas/index.js.map +1 -1
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts +38 -16
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +205 -189
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
- package/dist/services/canvas/providers/duckdb/exportWriter.d.ts +13 -25
- package/dist/services/canvas/providers/duckdb/exportWriter.d.ts.map +1 -1
- package/dist/services/canvas/providers/duckdb/exportWriter.js +15 -29
- package/dist/services/canvas/providers/duckdb/exportWriter.js.map +1 -1
- package/dist/services/canvas/providers/duckdb/schemaSniffer.d.ts +19 -26
- package/dist/services/canvas/providers/duckdb/schemaSniffer.d.ts.map +1 -1
- package/dist/services/canvas/providers/duckdb/schemaSniffer.js +30 -56
- package/dist/services/canvas/providers/duckdb/schemaSniffer.js.map +1 -1
- package/dist/services/canvas/types.d.ts +1 -2
- package/dist/services/canvas/types.d.ts.map +1 -1
- package/dist/services/canvas/types.js +1 -2
- package/dist/services/canvas/types.js.map +1 -1
- package/dist/utils/internal/requestContext.d.ts +9 -4
- package/dist/utils/internal/requestContext.d.ts.map +1 -1
- package/dist/utils/internal/requestContext.js.map +1 -1
- package/package.json +2 -2
- package/dist/logs/combined.log +0 -4
- package/dist/logs/error.log +0 -4
- package/dist/logs/interactions.log +0 -0
|
@@ -1,30 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview DuckDB-backed
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Lazy-loaded via {@link lazyImport} so `@duckdb/node-api` stays a true peer
|
|
9
|
-
* dependency — servers that don't enable canvas pay no install cost.
|
|
10
|
-
*
|
|
2
|
+
* @fileoverview DuckDB-backed {@link IDataCanvasProvider}. One in-memory
|
|
3
|
+
* DuckDB instance per canvasId; a long-lived control connection for DDL and
|
|
4
|
+
* describe operations, plus a per-query connection so cancellation interrupts
|
|
5
|
+
* exactly the in-flight query. `@duckdb/node-api` is lazy-loaded so it stays
|
|
6
|
+
* a true peer dependency.
|
|
11
7
|
* @module src/services/canvas/providers/duckdb/DuckdbProvider
|
|
12
8
|
*/
|
|
13
|
-
import {
|
|
9
|
+
import { unlink } from 'node:fs/promises';
|
|
10
|
+
import { databaseError, notFound, timeout, validationError } from '../../../../types-global/errors.js';
|
|
14
11
|
import { lazyImport } from '../../../../utils/internal/lazyImport.js';
|
|
15
12
|
import { logger } from '../../../../utils/internal/logger.js';
|
|
16
13
|
import { requestContextService } from '../../../../utils/internal/requestContext.js';
|
|
17
|
-
import { assertPlanReadOnly, assertSelectOnly, assertValidIdentifier, quoteIdentifier, } from '../../core/sqlGate.js';
|
|
14
|
+
import { assertNoDeniedFunctions, assertPlanReadOnly, assertSelectOnly, assertValidIdentifier, quoteIdentifier, } from '../../core/sqlGate.js';
|
|
18
15
|
import { copyFormatClause, isPathTarget, pipeFileToStream, resolveExportPath, safeSizeBytes, tempFilePathFor, } from './exportWriter.js';
|
|
19
16
|
import { sniffSchema } from './schemaSniffer.js';
|
|
20
|
-
/** Lazy import binding — preserves bundler-friendly module specifiers. */
|
|
21
17
|
const importDuckDB = lazyImport(() => import('@duckdb/node-api'), 'Install "@duckdb/node-api" to use the DuckDB canvas provider: bun add @duckdb/node-api');
|
|
22
|
-
/** DuckDB SELECT statement-type id (matches `StatementType.SELECT === 1`). */
|
|
23
|
-
const STATEMENT_TYPE_SELECT_ID = 1;
|
|
24
18
|
export class DuckdbProvider {
|
|
25
19
|
options;
|
|
26
20
|
name = 'duckdb';
|
|
27
|
-
duck;
|
|
28
21
|
canvases = new Map();
|
|
29
22
|
constructor(options) {
|
|
30
23
|
this.options = options;
|
|
@@ -35,15 +28,14 @@ export class DuckdbProvider {
|
|
|
35
28
|
async initCanvas(canvasId, _context) {
|
|
36
29
|
if (this.canvases.has(canvasId))
|
|
37
30
|
return;
|
|
38
|
-
const duck = await
|
|
31
|
+
const duck = await importDuckDB();
|
|
39
32
|
const instance = await duck.DuckDBInstance.create(':memory:', {
|
|
40
33
|
memory_limit: `${this.options.memoryLimitMb}MB`,
|
|
41
|
-
// Disable
|
|
34
|
+
// Disable extension install/load paths in canvas mode.
|
|
42
35
|
autoinstall_known_extensions: 'false',
|
|
43
36
|
autoload_known_extensions: 'false',
|
|
44
37
|
});
|
|
45
38
|
const controlConnection = await instance.connect();
|
|
46
|
-
// Belt-and-suspenders — also set the limit on the connection.
|
|
47
39
|
await controlConnection.run(`SET memory_limit = '${this.options.memoryLimitMb}MB'`);
|
|
48
40
|
this.canvases.set(canvasId, { instance, controlConnection });
|
|
49
41
|
}
|
|
@@ -78,7 +70,7 @@ export class DuckdbProvider {
|
|
|
78
70
|
}
|
|
79
71
|
async healthCheck() {
|
|
80
72
|
try {
|
|
81
|
-
const duck = await
|
|
73
|
+
const duck = await importDuckDB();
|
|
82
74
|
const instance = await duck.DuckDBInstance.create(':memory:');
|
|
83
75
|
const conn = await instance.connect();
|
|
84
76
|
await conn.run('SELECT 1');
|
|
@@ -95,20 +87,20 @@ export class DuckdbProvider {
|
|
|
95
87
|
operation: 'DuckdbProvider.shutdown',
|
|
96
88
|
});
|
|
97
89
|
const ids = [...this.canvases.keys()];
|
|
98
|
-
|
|
99
|
-
await this.destroyCanvas(id, context);
|
|
100
|
-
}
|
|
90
|
+
await Promise.allSettled(ids.map((id) => this.destroyCanvas(id, context)));
|
|
101
91
|
}
|
|
102
92
|
// ---------------------------------------------------------------------
|
|
103
93
|
// Data plane
|
|
104
94
|
// ---------------------------------------------------------------------
|
|
105
95
|
async registerTable(canvasId, name, rows, _context, options) {
|
|
106
96
|
const record = this.requireCanvas(canvasId);
|
|
97
|
+
const duck = await importDuckDB();
|
|
107
98
|
assertValidIdentifier(name, 'table');
|
|
108
99
|
options?.signal?.throwIfAborted();
|
|
109
100
|
const isAsyncIterable = typeof rows[Symbol.asyncIterator] === 'function';
|
|
110
101
|
let schema;
|
|
111
102
|
let bufferedRows;
|
|
103
|
+
let remainingSync;
|
|
112
104
|
if (options?.schema) {
|
|
113
105
|
schema = options.schema;
|
|
114
106
|
}
|
|
@@ -119,12 +111,11 @@ export class DuckdbProvider {
|
|
|
119
111
|
const sniffed = sniffSchema(rows, this.options.schemaSniffRows);
|
|
120
112
|
schema = sniffed.schema;
|
|
121
113
|
bufferedRows = sniffed.sniffedRows;
|
|
114
|
+
remainingSync = sniffed.remaining;
|
|
122
115
|
}
|
|
123
116
|
for (const col of schema)
|
|
124
117
|
assertValidIdentifier(col.name, 'column');
|
|
125
|
-
//
|
|
126
|
-
// per acquire, but explicit drop+create makes register idempotent under
|
|
127
|
-
// re-registration of the same name.
|
|
118
|
+
// Drop+create makes register idempotent under re-registration of a name.
|
|
128
119
|
const ddl = buildCreateTableSql(name, schema);
|
|
129
120
|
await record.controlConnection.run(`DROP TABLE IF EXISTS ${quoteIdentifier(name)}`);
|
|
130
121
|
await record.controlConnection.run(ddl);
|
|
@@ -134,7 +125,7 @@ export class DuckdbProvider {
|
|
|
134
125
|
try {
|
|
135
126
|
const appendOne = (row) => {
|
|
136
127
|
for (const col of schema) {
|
|
137
|
-
appendValue(appender, col, row[col.name]);
|
|
128
|
+
appendValue(appender, col, row[col.name], duck);
|
|
138
129
|
}
|
|
139
130
|
appender.endRow();
|
|
140
131
|
count += 1;
|
|
@@ -145,22 +136,25 @@ export class DuckdbProvider {
|
|
|
145
136
|
appendOne(row);
|
|
146
137
|
}
|
|
147
138
|
}
|
|
148
|
-
// Drain whatever's left of the iterable.
|
|
149
139
|
if (isAsyncIterable) {
|
|
150
140
|
for await (const row of rows) {
|
|
151
141
|
options?.signal?.throwIfAborted();
|
|
152
142
|
appendOne(row);
|
|
153
143
|
}
|
|
154
144
|
}
|
|
155
|
-
else if (
|
|
156
|
-
|
|
145
|
+
else if (remainingSync) {
|
|
146
|
+
// Continuation iterator from the sniffer — picks up just past
|
|
147
|
+
// bufferedRows so we don't re-iterate (which would drop data on
|
|
148
|
+
// generators or duplicate rows from fresh-iterator iterables).
|
|
149
|
+
let next = remainingSync.next();
|
|
150
|
+
while (!next.done) {
|
|
157
151
|
options?.signal?.throwIfAborted();
|
|
158
|
-
appendOne(
|
|
152
|
+
appendOne(next.value);
|
|
153
|
+
next = remainingSync.next();
|
|
159
154
|
}
|
|
160
155
|
}
|
|
161
156
|
else {
|
|
162
|
-
|
|
163
|
-
for (const row of skipFirst(rows, bufferedRows.length)) {
|
|
157
|
+
for (const row of rows) {
|
|
164
158
|
options?.signal?.throwIfAborted();
|
|
165
159
|
appendOne(row);
|
|
166
160
|
}
|
|
@@ -177,8 +171,12 @@ export class DuckdbProvider {
|
|
|
177
171
|
}
|
|
178
172
|
async query(canvasId, sql, _context, options) {
|
|
179
173
|
const record = this.requireCanvas(canvasId);
|
|
174
|
+
const duck = await importDuckDB();
|
|
180
175
|
options?.signal?.throwIfAborted();
|
|
181
|
-
//
|
|
176
|
+
// Layer 1: text-level deny-list. read_json/read_parquet/... lower into
|
|
177
|
+
// generic scans that pass the operator allowlist, so reject by name first.
|
|
178
|
+
assertNoDeniedFunctions(sql);
|
|
179
|
+
// Layers 2-3: parse and type-check before EXPLAIN.
|
|
182
180
|
const extracted = await record.controlConnection.extractStatements(sql);
|
|
183
181
|
const statementCount = extracted.count;
|
|
184
182
|
let statementType;
|
|
@@ -193,12 +191,12 @@ export class DuckdbProvider {
|
|
|
193
191
|
}
|
|
194
192
|
assertSelectOnly({
|
|
195
193
|
statementCount,
|
|
196
|
-
statementType:
|
|
194
|
+
statementType: statementType !== undefined ? (duck.StatementType[statementType] ?? 'UNKNOWN') : 'UNKNOWN',
|
|
197
195
|
});
|
|
198
|
-
//
|
|
196
|
+
// Layer 4: walk the plan with the allowlist + denied-function rescan.
|
|
199
197
|
const planJson = await this.runExplain(record.controlConnection, `EXPLAIN (FORMAT JSON) ${sql}`);
|
|
200
198
|
assertPlanReadOnly(planJson);
|
|
201
|
-
//
|
|
199
|
+
// Per-query connection so cancellation interrupts only this call.
|
|
202
200
|
const conn = await record.instance.connect();
|
|
203
201
|
let cancelled = false;
|
|
204
202
|
const onAbort = () => {
|
|
@@ -207,7 +205,7 @@ export class DuckdbProvider {
|
|
|
207
205
|
conn.interrupt();
|
|
208
206
|
}
|
|
209
207
|
catch {
|
|
210
|
-
|
|
208
|
+
/* interrupt is best-effort; closeSync still cleans up. */
|
|
211
209
|
}
|
|
212
210
|
};
|
|
213
211
|
options?.signal?.addEventListener('abort', onAbort, { once: true });
|
|
@@ -220,12 +218,10 @@ export class DuckdbProvider {
|
|
|
220
218
|
let totalRowCount = 0;
|
|
221
219
|
if (options?.registerAs) {
|
|
222
220
|
assertValidIdentifier(options.registerAs, 'table');
|
|
223
|
-
// Reject clash with existing canvas table.
|
|
224
221
|
await ensureTableMissing(record.controlConnection, options.registerAs);
|
|
225
222
|
const ctas = `CREATE TABLE ${quoteIdentifier(options.registerAs)} AS ${sql}`;
|
|
226
223
|
await conn.run(ctas);
|
|
227
224
|
registeredAs = options.registerAs;
|
|
228
|
-
// Read a preview off the new table (cheap because rows are local).
|
|
229
225
|
const reader = await conn.runAndReadUntil(`SELECT * FROM ${quoteIdentifier(options.registerAs)} LIMIT ${preview}`, preview);
|
|
230
226
|
rowsToReturn = reader.getRowObjectsJson();
|
|
231
227
|
columns = reader.columnNames();
|
|
@@ -249,7 +245,7 @@ export class DuckdbProvider {
|
|
|
249
245
|
}
|
|
250
246
|
catch (err) {
|
|
251
247
|
if (cancelled) {
|
|
252
|
-
throw
|
|
248
|
+
throw timeout('Canvas query was cancelled.', { reason: 'cancelled' }, { cause: err });
|
|
253
249
|
}
|
|
254
250
|
throw classifyDuckdbError(err);
|
|
255
251
|
}
|
|
@@ -259,7 +255,7 @@ export class DuckdbProvider {
|
|
|
259
255
|
conn.closeSync();
|
|
260
256
|
}
|
|
261
257
|
catch {
|
|
262
|
-
|
|
258
|
+
/* Connection may already be torn down by interrupt. */
|
|
263
259
|
}
|
|
264
260
|
}
|
|
265
261
|
}
|
|
@@ -269,17 +265,18 @@ export class DuckdbProvider {
|
|
|
269
265
|
options?.signal?.throwIfAborted();
|
|
270
266
|
const formatClause = copyFormatClause(target.format);
|
|
271
267
|
const conn = await record.instance.connect();
|
|
268
|
+
let cancelled = false;
|
|
272
269
|
const onAbort = () => {
|
|
270
|
+
cancelled = true;
|
|
273
271
|
try {
|
|
274
272
|
conn.interrupt();
|
|
275
273
|
}
|
|
276
274
|
catch {
|
|
277
|
-
/*
|
|
275
|
+
/* interrupt is best-effort. */
|
|
278
276
|
}
|
|
279
277
|
};
|
|
280
278
|
options?.signal?.addEventListener('abort', onAbort, { once: true });
|
|
281
279
|
try {
|
|
282
|
-
// Row count from the table — cheap, used for the result payload.
|
|
283
280
|
const countReader = await conn.runAndReadAll(`SELECT COUNT(*) AS n FROM ${quoteIdentifier(tableName)}`);
|
|
284
281
|
const countRow = countReader.getRowObjectsJson()[0];
|
|
285
282
|
const rowCount = Number(countRow?.n ?? 0);
|
|
@@ -294,10 +291,17 @@ export class DuckdbProvider {
|
|
|
294
291
|
rowCount,
|
|
295
292
|
};
|
|
296
293
|
}
|
|
297
|
-
// Stream branch:
|
|
298
|
-
// then unlink.
|
|
294
|
+
// Stream branch: COPY to a sandbox temp file, pipe to the caller's
|
|
295
|
+
// stream, then unlink. pipeFileToStream owns cleanup once invoked; if
|
|
296
|
+
// the COPY itself fails we must unlink here before re-throwing.
|
|
299
297
|
const tempPath = await tempFilePathFor(this.options.exportRootPath, target.format);
|
|
300
|
-
|
|
298
|
+
try {
|
|
299
|
+
await conn.run(`COPY ${quoteIdentifier(tableName)} TO '${escapeSqlString(tempPath)}' ${formatClause}`);
|
|
300
|
+
}
|
|
301
|
+
catch (copyErr) {
|
|
302
|
+
await unlink(tempPath).catch(() => { });
|
|
303
|
+
throw copyErr;
|
|
304
|
+
}
|
|
301
305
|
const { sizeBytes } = await pipeFileToStream(tempPath, target.stream);
|
|
302
306
|
return {
|
|
303
307
|
format: target.format,
|
|
@@ -306,6 +310,9 @@ export class DuckdbProvider {
|
|
|
306
310
|
};
|
|
307
311
|
}
|
|
308
312
|
catch (err) {
|
|
313
|
+
if (cancelled) {
|
|
314
|
+
throw timeout('Canvas export was cancelled.', { reason: 'cancelled' }, { cause: err });
|
|
315
|
+
}
|
|
309
316
|
throw classifyDuckdbError(err);
|
|
310
317
|
}
|
|
311
318
|
finally {
|
|
@@ -314,7 +321,7 @@ export class DuckdbProvider {
|
|
|
314
321
|
conn.closeSync();
|
|
315
322
|
}
|
|
316
323
|
catch {
|
|
317
|
-
/*
|
|
324
|
+
/* Already torn down by interrupt. */
|
|
318
325
|
}
|
|
319
326
|
}
|
|
320
327
|
}
|
|
@@ -328,27 +335,27 @@ export class DuckdbProvider {
|
|
|
328
335
|
: '';
|
|
329
336
|
const reader = await record.controlConnection.runAndReadAll(`SELECT table_name FROM information_schema.tables WHERE table_schema = 'main'${filter} ORDER BY table_name`);
|
|
330
337
|
const tableRows = reader.getRowObjectsJson();
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
338
|
+
return await Promise.all(tableRows.map((row) => this.describeOne(record.controlConnection, row.table_name)));
|
|
339
|
+
}
|
|
340
|
+
async describeOne(connection, tableName) {
|
|
341
|
+
const [colReader, countReader] = await Promise.all([
|
|
342
|
+
connection.runAndReadAll(`SELECT column_name, data_type, is_nullable FROM information_schema.columns ` +
|
|
335
343
|
`WHERE table_schema = 'main' AND table_name = '${escapeSqlString(tableName)}' ` +
|
|
336
|
-
`ORDER BY ordinal_position`)
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
351
|
-
return result;
|
|
344
|
+
`ORDER BY ordinal_position`),
|
|
345
|
+
connection.runAndReadAll(`SELECT COUNT(*) AS n FROM ${quoteIdentifier(tableName)}`),
|
|
346
|
+
]);
|
|
347
|
+
const colRows = colReader.getRowObjectsJson();
|
|
348
|
+
const columns = colRows.map((c) => ({
|
|
349
|
+
name: c.column_name,
|
|
350
|
+
type: dataTypeToColumnType(c.data_type),
|
|
351
|
+
nullable: c.is_nullable === 'YES',
|
|
352
|
+
}));
|
|
353
|
+
const countRow = countReader.getRowObjectsJson()[0];
|
|
354
|
+
return {
|
|
355
|
+
name: tableName,
|
|
356
|
+
rowCount: Number(countRow?.n ?? 0),
|
|
357
|
+
columns,
|
|
358
|
+
};
|
|
352
359
|
}
|
|
353
360
|
async drop(canvasId, name, _context) {
|
|
354
361
|
const record = this.requireCanvas(canvasId);
|
|
@@ -378,26 +385,22 @@ export class DuckdbProvider {
|
|
|
378
385
|
}
|
|
379
386
|
return record;
|
|
380
387
|
}
|
|
381
|
-
async getModule() {
|
|
382
|
-
if (!this.duck)
|
|
383
|
-
this.duck = await importDuckDB();
|
|
384
|
-
return this.duck;
|
|
385
|
-
}
|
|
386
388
|
async runExplain(connection, explainSql) {
|
|
387
389
|
const reader = await connection.runAndReadAll(explainSql);
|
|
388
390
|
const rows = reader.getRowObjectsJson();
|
|
389
|
-
// EXPLAIN returns
|
|
390
|
-
// the
|
|
391
|
+
// EXPLAIN returns one row with `explain_value` as the JSON tree string.
|
|
392
|
+
// Fail loud if the shape changes — a silent fallback would let queries
|
|
393
|
+
// bypass the plan-walk gate.
|
|
391
394
|
const value = rows[0]?.explain_value;
|
|
392
|
-
if (typeof value
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
395
|
+
if (typeof value !== 'string') {
|
|
396
|
+
throw databaseError('EXPLAIN returned an unexpected shape; canvas plan-walk cannot run safely.', { rowCount: rows.length, hasExplainValue: rows[0]?.explain_value !== undefined });
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
return JSON.parse(value);
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
throw databaseError('Failed to parse EXPLAIN plan JSON.', undefined, { cause: err });
|
|
399
403
|
}
|
|
400
|
-
return rows;
|
|
401
404
|
}
|
|
402
405
|
}
|
|
403
406
|
// ---------------------------------------------------------------------------
|
|
@@ -409,32 +412,10 @@ function buildCreateTableSql(tableName, schema) {
|
|
|
409
412
|
}
|
|
410
413
|
const cols = schema.map((c) => {
|
|
411
414
|
const nullable = c.nullable === false ? ' NOT NULL' : '';
|
|
412
|
-
return `${quoteIdentifier(c.name)} ${
|
|
415
|
+
return `${quoteIdentifier(c.name)} ${c.type}${nullable}`;
|
|
413
416
|
});
|
|
414
417
|
return `CREATE TABLE ${quoteIdentifier(tableName)} (${cols.join(', ')})`;
|
|
415
418
|
}
|
|
416
|
-
function columnTypeToSql(type) {
|
|
417
|
-
switch (type) {
|
|
418
|
-
case 'VARCHAR':
|
|
419
|
-
return 'VARCHAR';
|
|
420
|
-
case 'INTEGER':
|
|
421
|
-
return 'INTEGER';
|
|
422
|
-
case 'BIGINT':
|
|
423
|
-
return 'BIGINT';
|
|
424
|
-
case 'DOUBLE':
|
|
425
|
-
return 'DOUBLE';
|
|
426
|
-
case 'BOOLEAN':
|
|
427
|
-
return 'BOOLEAN';
|
|
428
|
-
case 'TIMESTAMP':
|
|
429
|
-
return 'TIMESTAMP';
|
|
430
|
-
case 'DATE':
|
|
431
|
-
return 'DATE';
|
|
432
|
-
case 'JSON':
|
|
433
|
-
return 'JSON';
|
|
434
|
-
case 'BLOB':
|
|
435
|
-
return 'BLOB';
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
419
|
/** Map DuckDB `information_schema.columns.data_type` strings back to {@link ColumnType}. */
|
|
439
420
|
function dataTypeToColumnType(dataType) {
|
|
440
421
|
const upper = dataType.toUpperCase();
|
|
@@ -458,71 +439,13 @@ function dataTypeToColumnType(dataType) {
|
|
|
458
439
|
return 'BLOB';
|
|
459
440
|
return 'VARCHAR';
|
|
460
441
|
}
|
|
461
|
-
/**
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
case 3:
|
|
469
|
-
return 'UPDATE';
|
|
470
|
-
case 4:
|
|
471
|
-
return 'EXPLAIN';
|
|
472
|
-
case 5:
|
|
473
|
-
return 'DELETE';
|
|
474
|
-
case 6:
|
|
475
|
-
return 'PREPARE';
|
|
476
|
-
case 7:
|
|
477
|
-
return 'CREATE';
|
|
478
|
-
case 8:
|
|
479
|
-
return 'EXECUTE';
|
|
480
|
-
case 9:
|
|
481
|
-
return 'ALTER';
|
|
482
|
-
case 10:
|
|
483
|
-
return 'TRANSACTION';
|
|
484
|
-
case 11:
|
|
485
|
-
return 'COPY';
|
|
486
|
-
case 12:
|
|
487
|
-
return 'ANALYZE';
|
|
488
|
-
case 13:
|
|
489
|
-
return 'VARIABLE_SET';
|
|
490
|
-
case 14:
|
|
491
|
-
return 'CREATE_FUNC';
|
|
492
|
-
case 15:
|
|
493
|
-
return 'DROP';
|
|
494
|
-
case 16:
|
|
495
|
-
return 'EXPORT';
|
|
496
|
-
case 17:
|
|
497
|
-
return 'PRAGMA';
|
|
498
|
-
case 18:
|
|
499
|
-
return 'VACUUM';
|
|
500
|
-
case 19:
|
|
501
|
-
return 'CALL';
|
|
502
|
-
case 20:
|
|
503
|
-
return 'SET';
|
|
504
|
-
case 21:
|
|
505
|
-
return 'LOAD';
|
|
506
|
-
case 22:
|
|
507
|
-
return 'RELATION';
|
|
508
|
-
case 23:
|
|
509
|
-
return 'EXTENSION';
|
|
510
|
-
case 24:
|
|
511
|
-
return 'LOGICAL_PLAN';
|
|
512
|
-
case 25:
|
|
513
|
-
return 'ATTACH';
|
|
514
|
-
case 26:
|
|
515
|
-
return 'DETACH';
|
|
516
|
-
case 27:
|
|
517
|
-
return 'MULTI';
|
|
518
|
-
default:
|
|
519
|
-
return 'UNKNOWN';
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
/** Re-export for tests and consumer-side parsing. */
|
|
523
|
-
export { STATEMENT_TYPE_SELECT_ID };
|
|
524
|
-
/** Append a single value via the DuckDB appender, dispatched on column type. */
|
|
525
|
-
function appendValue(appender, col, value) {
|
|
442
|
+
/**
|
|
443
|
+
* Append a value through the DuckDB appender, dispatched by column type.
|
|
444
|
+
* `duck` carries the typed value constructors needed for TIMESTAMP/DATE.
|
|
445
|
+
* Incompatible values fail fast with a structured `validationError` rather
|
|
446
|
+
* than coercing through `String(value)` (which corrupts Dates and binary).
|
|
447
|
+
*/
|
|
448
|
+
function appendValue(appender, col, value, duck) {
|
|
526
449
|
if (value === null || value === undefined) {
|
|
527
450
|
appender.appendNull();
|
|
528
451
|
return;
|
|
@@ -535,7 +458,7 @@ function appendValue(appender, col, value) {
|
|
|
535
458
|
appender.appendInteger(Number(value));
|
|
536
459
|
return;
|
|
537
460
|
case 'BIGINT':
|
|
538
|
-
appender.appendBigInt(
|
|
461
|
+
appender.appendBigInt(toBigInt(value));
|
|
539
462
|
return;
|
|
540
463
|
case 'DOUBLE':
|
|
541
464
|
appender.appendDouble(Number(value));
|
|
@@ -547,22 +470,121 @@ function appendValue(appender, col, value) {
|
|
|
547
470
|
appender.appendVarchar(typeof value === 'string' ? value : JSON.stringify(value));
|
|
548
471
|
return;
|
|
549
472
|
case 'TIMESTAMP':
|
|
473
|
+
appender.appendTimestamp(new duck.DuckDBTimestampValue(toTimestampMicros(value, col.name)));
|
|
474
|
+
return;
|
|
550
475
|
case 'DATE':
|
|
476
|
+
appender.appendDate(new duck.DuckDBDateValue(toDateDays(value, col.name)));
|
|
477
|
+
return;
|
|
551
478
|
case 'BLOB':
|
|
552
|
-
|
|
553
|
-
appender.appendVarchar(String(value));
|
|
479
|
+
appender.appendBlob(toUint8Array(value, col.name));
|
|
554
480
|
return;
|
|
555
481
|
}
|
|
556
482
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
483
|
+
/**
|
|
484
|
+
* Coerce a value to BigInt without precision loss. `BigInt(Number(value))`
|
|
485
|
+
* round-trips through JS Number and truncates outside the 53-bit safe range,
|
|
486
|
+
* silently corrupting BIGINT IDs returned as numeric strings.
|
|
487
|
+
*
|
|
488
|
+
* @internal Exported for unit testing.
|
|
489
|
+
*/
|
|
490
|
+
export function toBigInt(value) {
|
|
491
|
+
if (typeof value === 'bigint')
|
|
492
|
+
return value;
|
|
493
|
+
if (typeof value === 'string' && /^-?\d+$/.test(value))
|
|
494
|
+
return BigInt(value);
|
|
495
|
+
return BigInt(Math.trunc(Number(value)));
|
|
496
|
+
}
|
|
497
|
+
const MS_PER_DAY = 86_400_000;
|
|
498
|
+
/**
|
|
499
|
+
* Coerce to DuckDB's TIMESTAMP unit (micros since 1970-01-01 UTC, as `bigint`).
|
|
500
|
+
* Accepts `Date`, `bigint` (already-micros), `number` (ms-since-epoch matching
|
|
501
|
+
* `Date.getTime()`), and ISO 8601 strings. Throws `validationError` otherwise.
|
|
502
|
+
*
|
|
503
|
+
* @internal Exported for unit testing.
|
|
504
|
+
*/
|
|
505
|
+
export function toTimestampMicros(value, columnName) {
|
|
506
|
+
if (value instanceof Date) {
|
|
507
|
+
const ms = value.getTime();
|
|
508
|
+
if (!Number.isFinite(ms)) {
|
|
509
|
+
throw validationError(`Invalid Date for TIMESTAMP column "${columnName}".`, {
|
|
510
|
+
reason: 'invalid_value_for_type',
|
|
511
|
+
column: columnName,
|
|
512
|
+
type: 'TIMESTAMP',
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
return BigInt(ms) * 1000n;
|
|
516
|
+
}
|
|
517
|
+
if (typeof value === 'bigint')
|
|
518
|
+
return value;
|
|
519
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
520
|
+
return BigInt(Math.trunc(value)) * 1000n;
|
|
521
|
+
}
|
|
522
|
+
if (typeof value === 'string') {
|
|
523
|
+
const ms = Date.parse(value);
|
|
524
|
+
if (Number.isFinite(ms))
|
|
525
|
+
return BigInt(ms) * 1000n;
|
|
526
|
+
}
|
|
527
|
+
throw validationError(`Cannot append ${describeValueType(value)} to TIMESTAMP column "${columnName}". Expected Date, ISO 8601 string, number (ms epoch), or bigint (micros epoch).`, { reason: 'invalid_value_for_type', column: columnName, type: 'TIMESTAMP' });
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Coerce to DuckDB's DATE unit (days since 1970-01-01 UTC, as `number`).
|
|
531
|
+
* Accepts the same shapes as {@link toTimestampMicros}; throws otherwise.
|
|
532
|
+
*
|
|
533
|
+
* @internal Exported for unit testing.
|
|
534
|
+
*/
|
|
535
|
+
export function toDateDays(value, columnName) {
|
|
536
|
+
if (value instanceof Date) {
|
|
537
|
+
const ms = value.getTime();
|
|
538
|
+
if (!Number.isFinite(ms)) {
|
|
539
|
+
throw validationError(`Invalid Date for DATE column "${columnName}".`, {
|
|
540
|
+
reason: 'invalid_value_for_type',
|
|
541
|
+
column: columnName,
|
|
542
|
+
type: 'DATE',
|
|
543
|
+
});
|
|
563
544
|
}
|
|
564
|
-
|
|
545
|
+
return Math.floor(ms / MS_PER_DAY);
|
|
546
|
+
}
|
|
547
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
548
|
+
return Math.floor(value / MS_PER_DAY);
|
|
565
549
|
}
|
|
550
|
+
if (typeof value === 'string') {
|
|
551
|
+
const ms = Date.parse(value);
|
|
552
|
+
if (Number.isFinite(ms))
|
|
553
|
+
return Math.floor(ms / MS_PER_DAY);
|
|
554
|
+
}
|
|
555
|
+
throw validationError(`Cannot append ${describeValueType(value)} to DATE column "${columnName}". Expected Date, ISO 8601 date string, or number (ms epoch).`, { reason: 'invalid_value_for_type', column: columnName, type: 'DATE' });
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Coerce to `Uint8Array` for BLOB appends. Accepts `Uint8Array` (Node's
|
|
559
|
+
* `Buffer` passes through as a subclass), `ArrayBuffer`, and any other
|
|
560
|
+
* `ArrayBufferView`. Throws `validationError` for non-binary inputs.
|
|
561
|
+
*
|
|
562
|
+
* @internal Exported for unit testing.
|
|
563
|
+
*/
|
|
564
|
+
export function toUint8Array(value, columnName) {
|
|
565
|
+
if (value instanceof Uint8Array)
|
|
566
|
+
return value;
|
|
567
|
+
if (value instanceof ArrayBuffer)
|
|
568
|
+
return new Uint8Array(value);
|
|
569
|
+
if (ArrayBuffer.isView(value)) {
|
|
570
|
+
const view = value;
|
|
571
|
+
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
572
|
+
}
|
|
573
|
+
throw validationError(`Cannot append ${describeValueType(value)} to BLOB column "${columnName}". Expected Uint8Array, Buffer, or ArrayBuffer.`, { reason: 'invalid_value_for_type', column: columnName, type: 'BLOB' });
|
|
574
|
+
}
|
|
575
|
+
/** Tag describing a runtime value's type, for error messages. */
|
|
576
|
+
function describeValueType(value) {
|
|
577
|
+
if (value === null)
|
|
578
|
+
return 'null';
|
|
579
|
+
if (Array.isArray(value))
|
|
580
|
+
return 'array';
|
|
581
|
+
if (value instanceof Date)
|
|
582
|
+
return 'Date';
|
|
583
|
+
if (value instanceof Uint8Array)
|
|
584
|
+
return 'Uint8Array';
|
|
585
|
+
if (value instanceof ArrayBuffer)
|
|
586
|
+
return 'ArrayBuffer';
|
|
587
|
+
return typeof value;
|
|
566
588
|
}
|
|
567
589
|
/** Escape a string literal for safe inclusion in `'...'` SQL contexts. */
|
|
568
590
|
function escapeSqlString(value) {
|
|
@@ -576,22 +598,16 @@ async function ensureTableMissing(connection, tableName) {
|
|
|
576
598
|
}
|
|
577
599
|
/**
|
|
578
600
|
* Map a DuckDB-thrown error to a framework error class.
|
|
579
|
-
* @internal Exported for unit testing
|
|
601
|
+
* @internal Exported for unit testing.
|
|
580
602
|
*/
|
|
581
603
|
export function classifyDuckdbError(err) {
|
|
582
604
|
if (err instanceof Error) {
|
|
583
605
|
const msg = err.message;
|
|
584
|
-
// DuckDB tends to throw `Error` with a descriptive message; keep its
|
|
585
|
-
// identity as the cause and attach a structured message.
|
|
586
606
|
if (/parser error|syntax/i.test(msg)) {
|
|
587
|
-
return validationError(`Canvas SQL rejected: ${msg}`, { reason: 'sql_parse_error' }, {
|
|
588
|
-
cause: err,
|
|
589
|
-
});
|
|
607
|
+
return validationError(`Canvas SQL rejected: ${msg}`, { reason: 'sql_parse_error' }, { cause: err });
|
|
590
608
|
}
|
|
591
609
|
if (/permission|read.?only/i.test(msg)) {
|
|
592
|
-
return validationError(`Canvas SQL rejected: ${msg}`, { reason: 'sql_read_only' }, {
|
|
593
|
-
cause: err,
|
|
594
|
-
});
|
|
610
|
+
return validationError(`Canvas SQL rejected: ${msg}`, { reason: 'sql_read_only' }, { cause: err });
|
|
595
611
|
}
|
|
596
612
|
return databaseError(msg, undefined, { cause: err });
|
|
597
613
|
}
|