@cyanheads/mcp-ts-core 0.8.11 → 0.8.13
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/changelog/0.8.x/0.8.13.md +27 -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/logs/combined.log +4 -4
- package/dist/logs/error.log +4 -4
- package/dist/services/canvas/core/CanvasInstance.d.ts +16 -5
- package/dist/services/canvas/core/CanvasInstance.d.ts.map +1 -1
- package/dist/services/canvas/core/CanvasInstance.js +22 -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 +24 -6
- package/dist/services/canvas/core/IDataCanvasProvider.d.ts.map +1 -1
- 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 +90 -60
- package/dist/services/canvas/core/sqlGate.d.ts.map +1 -1
- package/dist/services/canvas/core/sqlGate.js +231 -84
- package/dist/services/canvas/core/sqlGate.js.map +1 -1
- package/dist/services/canvas/index.d.ts +2 -2
- 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 +59 -17
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +364 -222
- 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 +33 -6
- 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/skills/api-canvas/SKILL.md +42 -8
|
@@ -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,28 +171,10 @@ 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
|
-
|
|
182
|
-
|
|
183
|
-
const statementCount = extracted.count;
|
|
184
|
-
let statementType;
|
|
185
|
-
if (statementCount === 1) {
|
|
186
|
-
const prepared = await extracted.prepare(0);
|
|
187
|
-
try {
|
|
188
|
-
statementType = prepared.statementType;
|
|
189
|
-
}
|
|
190
|
-
finally {
|
|
191
|
-
prepared.destroySync();
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
assertSelectOnly({
|
|
195
|
-
statementCount,
|
|
196
|
-
statementType: statementTypeIdToString(statementType),
|
|
197
|
-
});
|
|
198
|
-
// Step 2: walk the plan with the allowlist (defense in depth).
|
|
199
|
-
const planJson = await this.runExplain(record.controlConnection, `EXPLAIN (FORMAT JSON) ${sql}`);
|
|
200
|
-
assertPlanReadOnly(planJson);
|
|
201
|
-
// Step 3: per-query connection so cancellation is scoped to this call.
|
|
176
|
+
await this.assertReadOnlySql(record, sql, duck);
|
|
177
|
+
// Per-query connection so cancellation interrupts only this call.
|
|
202
178
|
const conn = await record.instance.connect();
|
|
203
179
|
let cancelled = false;
|
|
204
180
|
const onAbort = () => {
|
|
@@ -207,7 +183,7 @@ export class DuckdbProvider {
|
|
|
207
183
|
conn.interrupt();
|
|
208
184
|
}
|
|
209
185
|
catch {
|
|
210
|
-
|
|
186
|
+
/* interrupt is best-effort; closeSync still cleans up. */
|
|
211
187
|
}
|
|
212
188
|
};
|
|
213
189
|
options?.signal?.addEventListener('abort', onAbort, { once: true });
|
|
@@ -220,18 +196,14 @@ export class DuckdbProvider {
|
|
|
220
196
|
let totalRowCount = 0;
|
|
221
197
|
if (options?.registerAs) {
|
|
222
198
|
assertValidIdentifier(options.registerAs, 'table');
|
|
223
|
-
// Reject clash with existing canvas table.
|
|
224
199
|
await ensureTableMissing(record.controlConnection, options.registerAs);
|
|
225
200
|
const ctas = `CREATE TABLE ${quoteIdentifier(options.registerAs)} AS ${sql}`;
|
|
226
201
|
await conn.run(ctas);
|
|
227
202
|
registeredAs = options.registerAs;
|
|
228
|
-
// Read a preview off the new table (cheap because rows are local).
|
|
229
203
|
const reader = await conn.runAndReadUntil(`SELECT * FROM ${quoteIdentifier(options.registerAs)} LIMIT ${preview}`, preview);
|
|
230
204
|
rowsToReturn = reader.getRowObjectsJson();
|
|
231
205
|
columns = reader.columnNames();
|
|
232
|
-
|
|
233
|
-
const countRow = countReader.getRowObjectsJson()[0];
|
|
234
|
-
totalRowCount = Number(countRow?.n ?? 0);
|
|
206
|
+
totalRowCount = await this.countRows(conn, options.registerAs);
|
|
235
207
|
}
|
|
236
208
|
else {
|
|
237
209
|
const reader = await conn.runAndReadAll(sql);
|
|
@@ -249,7 +221,7 @@ export class DuckdbProvider {
|
|
|
249
221
|
}
|
|
250
222
|
catch (err) {
|
|
251
223
|
if (cancelled) {
|
|
252
|
-
throw
|
|
224
|
+
throw timeout('Canvas query was cancelled.', { reason: 'cancelled' }, { cause: err });
|
|
253
225
|
}
|
|
254
226
|
throw classifyDuckdbError(err);
|
|
255
227
|
}
|
|
@@ -259,7 +231,7 @@ export class DuckdbProvider {
|
|
|
259
231
|
conn.closeSync();
|
|
260
232
|
}
|
|
261
233
|
catch {
|
|
262
|
-
|
|
234
|
+
/* Connection may already be torn down by interrupt. */
|
|
263
235
|
}
|
|
264
236
|
}
|
|
265
237
|
}
|
|
@@ -269,20 +241,19 @@ export class DuckdbProvider {
|
|
|
269
241
|
options?.signal?.throwIfAborted();
|
|
270
242
|
const formatClause = copyFormatClause(target.format);
|
|
271
243
|
const conn = await record.instance.connect();
|
|
244
|
+
let cancelled = false;
|
|
272
245
|
const onAbort = () => {
|
|
246
|
+
cancelled = true;
|
|
273
247
|
try {
|
|
274
248
|
conn.interrupt();
|
|
275
249
|
}
|
|
276
250
|
catch {
|
|
277
|
-
/*
|
|
251
|
+
/* interrupt is best-effort. */
|
|
278
252
|
}
|
|
279
253
|
};
|
|
280
254
|
options?.signal?.addEventListener('abort', onAbort, { once: true });
|
|
281
255
|
try {
|
|
282
|
-
|
|
283
|
-
const countReader = await conn.runAndReadAll(`SELECT COUNT(*) AS n FROM ${quoteIdentifier(tableName)}`);
|
|
284
|
-
const countRow = countReader.getRowObjectsJson()[0];
|
|
285
|
-
const rowCount = Number(countRow?.n ?? 0);
|
|
256
|
+
const rowCount = await this.countRows(conn, tableName);
|
|
286
257
|
if (isPathTarget(target)) {
|
|
287
258
|
const absolutePath = await resolveExportPath(this.options.exportRootPath, target.path);
|
|
288
259
|
await conn.run(`COPY ${quoteIdentifier(tableName)} TO '${escapeSqlString(absolutePath)}' ${formatClause}`);
|
|
@@ -294,10 +265,17 @@ export class DuckdbProvider {
|
|
|
294
265
|
rowCount,
|
|
295
266
|
};
|
|
296
267
|
}
|
|
297
|
-
// Stream branch:
|
|
298
|
-
// then unlink.
|
|
268
|
+
// Stream branch: COPY to a sandbox temp file, pipe to the caller's
|
|
269
|
+
// stream, then unlink. pipeFileToStream owns cleanup once invoked; if
|
|
270
|
+
// the COPY itself fails we must unlink here before re-throwing.
|
|
299
271
|
const tempPath = await tempFilePathFor(this.options.exportRootPath, target.format);
|
|
300
|
-
|
|
272
|
+
try {
|
|
273
|
+
await conn.run(`COPY ${quoteIdentifier(tableName)} TO '${escapeSqlString(tempPath)}' ${formatClause}`);
|
|
274
|
+
}
|
|
275
|
+
catch (copyErr) {
|
|
276
|
+
await unlink(tempPath).catch(() => { });
|
|
277
|
+
throw copyErr;
|
|
278
|
+
}
|
|
301
279
|
const { sizeBytes } = await pipeFileToStream(tempPath, target.stream);
|
|
302
280
|
return {
|
|
303
281
|
format: target.format,
|
|
@@ -306,6 +284,9 @@ export class DuckdbProvider {
|
|
|
306
284
|
};
|
|
307
285
|
}
|
|
308
286
|
catch (err) {
|
|
287
|
+
if (cancelled) {
|
|
288
|
+
throw timeout('Canvas export was cancelled.', { reason: 'cancelled' }, { cause: err });
|
|
289
|
+
}
|
|
309
290
|
throw classifyDuckdbError(err);
|
|
310
291
|
}
|
|
311
292
|
finally {
|
|
@@ -314,57 +295,156 @@ export class DuckdbProvider {
|
|
|
314
295
|
conn.closeSync();
|
|
315
296
|
}
|
|
316
297
|
catch {
|
|
317
|
-
/*
|
|
298
|
+
/* Already torn down by interrupt. */
|
|
318
299
|
}
|
|
319
300
|
}
|
|
320
301
|
}
|
|
302
|
+
async registerView(canvasId, name, selectSql, _context, options) {
|
|
303
|
+
const record = this.requireCanvas(canvasId);
|
|
304
|
+
const duck = await importDuckDB();
|
|
305
|
+
assertValidIdentifier(name, 'table');
|
|
306
|
+
options?.signal?.throwIfAborted();
|
|
307
|
+
// Same four-layer gate `query()` enforces. View definitions inherit the
|
|
308
|
+
// operator allowlist transitively at query time, but we also gate the
|
|
309
|
+
// SELECT at registration so a malicious definition fails loud here, not
|
|
310
|
+
// later when the view is referenced.
|
|
311
|
+
await this.assertReadOnlySql(record, selectSql, duck);
|
|
312
|
+
options?.signal?.throwIfAborted();
|
|
313
|
+
// Block view-on-table-name collisions explicitly so the failure carries a
|
|
314
|
+
// structured `reason` rather than a raw DuckDB catalog message.
|
|
315
|
+
const existing = await this.lookupKind(record.controlConnection, name);
|
|
316
|
+
if (existing === 'table') {
|
|
317
|
+
throw validationError(`Canvas already contains a base table named "${name}". Drop the table or choose a different name.`, { reason: 'view_table_clash', name });
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
await record.controlConnection.run(`CREATE OR REPLACE VIEW ${quoteIdentifier(name)} AS ${selectSql}`);
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
throw classifyDuckdbError(err);
|
|
324
|
+
}
|
|
325
|
+
const colReader = await record.controlConnection.runAndReadAll(`SELECT column_name FROM information_schema.columns ` +
|
|
326
|
+
`WHERE table_schema = 'main' AND table_name = '${escapeSqlString(name)}' ` +
|
|
327
|
+
`ORDER BY ordinal_position`);
|
|
328
|
+
const columns = colReader.getRowObjectsJson().map((r) => r.column_name);
|
|
329
|
+
return { viewName: name, columns };
|
|
330
|
+
}
|
|
331
|
+
async importFrom(targetCanvasId, sourceCanvasId, sourceTableName, asName, _context, options) {
|
|
332
|
+
if (sourceCanvasId === targetCanvasId) {
|
|
333
|
+
throw validationError('Source and target canvases must differ. Use registerAs in query() to materialize within a single canvas.', { reason: 'import_same_canvas' });
|
|
334
|
+
}
|
|
335
|
+
const target = this.requireCanvas(targetCanvasId);
|
|
336
|
+
const source = this.requireCanvas(sourceCanvasId);
|
|
337
|
+
assertValidIdentifier(sourceTableName, 'table');
|
|
338
|
+
assertValidIdentifier(asName, 'table');
|
|
339
|
+
options?.signal?.throwIfAborted();
|
|
340
|
+
const sourceKind = await this.lookupKind(source.controlConnection, sourceTableName);
|
|
341
|
+
if (sourceKind === undefined) {
|
|
342
|
+
throw notFound(`Source canvas does not contain a table or view named "${sourceTableName}".`, {
|
|
343
|
+
sourceCanvasId,
|
|
344
|
+
sourceTableName,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
const targetExisting = await this.lookupKind(target.controlConnection, asName);
|
|
348
|
+
if (targetExisting === 'view') {
|
|
349
|
+
throw validationError(`Target canvas already contains a view named "${asName}". Drop the view or choose a different name.`, { reason: 'import_view_clash', asName });
|
|
350
|
+
}
|
|
351
|
+
// Drop+create makes import idempotent under re-imports of the same name,
|
|
352
|
+
// matching registerTable's behavior.
|
|
353
|
+
await target.controlConnection.run(`DROP TABLE IF EXISTS ${quoteIdentifier(asName)}`);
|
|
354
|
+
// Round-trip through a sandbox-rooted temp Parquet file. Parquet is
|
|
355
|
+
// built into DuckDB's core (no extension load needed even with
|
|
356
|
+
// autoload disabled). All column types — including TIMESTAMP/DATE/BLOB
|
|
357
|
+
// — round-trip losslessly, which an in-memory appender path can't
|
|
358
|
+
// guarantee for native engine value types.
|
|
359
|
+
const tempPath = await tempFilePathFor(this.options.exportRootPath, 'parquet');
|
|
360
|
+
try {
|
|
361
|
+
await source.controlConnection.run(`COPY ${quoteIdentifier(sourceTableName)} TO '${escapeSqlString(tempPath)}' (FORMAT 'parquet')`);
|
|
362
|
+
options?.signal?.throwIfAborted();
|
|
363
|
+
await target.controlConnection.run(`CREATE TABLE ${quoteIdentifier(asName)} AS SELECT * FROM read_parquet('${escapeSqlString(tempPath)}')`);
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
// Best-effort cleanup of a half-written target before surfacing.
|
|
367
|
+
await target.controlConnection
|
|
368
|
+
.run(`DROP TABLE IF EXISTS ${quoteIdentifier(asName)}`)
|
|
369
|
+
.catch(() => { });
|
|
370
|
+
throw classifyDuckdbError(err);
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
await unlink(tempPath).catch(() => { });
|
|
374
|
+
}
|
|
375
|
+
const [colReader, rowCount] = await Promise.all([
|
|
376
|
+
target.controlConnection.runAndReadAll(`SELECT column_name FROM information_schema.columns ` +
|
|
377
|
+
`WHERE table_schema = 'main' AND table_name = '${escapeSqlString(asName)}' ` +
|
|
378
|
+
`ORDER BY ordinal_position`),
|
|
379
|
+
this.countRows(target.controlConnection, asName),
|
|
380
|
+
]);
|
|
381
|
+
const columns = colReader.getRowObjectsJson().map((r) => r.column_name);
|
|
382
|
+
return { tableName: asName, rowCount, columns };
|
|
383
|
+
}
|
|
321
384
|
async describe(canvasId, _context, options) {
|
|
322
385
|
const record = this.requireCanvas(canvasId);
|
|
323
386
|
if (options?.tableName !== undefined) {
|
|
324
387
|
assertValidIdentifier(options.tableName, 'table');
|
|
325
388
|
}
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
389
|
+
const filters = [`table_schema = 'main'`];
|
|
390
|
+
if (options?.tableName) {
|
|
391
|
+
filters.push(`table_name = '${escapeSqlString(options.tableName)}'`);
|
|
392
|
+
}
|
|
393
|
+
if (options?.kind === 'view') {
|
|
394
|
+
filters.push(`table_type = 'VIEW'`);
|
|
395
|
+
}
|
|
396
|
+
else if (options?.kind === 'table') {
|
|
397
|
+
filters.push(`table_type <> 'VIEW'`);
|
|
398
|
+
}
|
|
399
|
+
const reader = await record.controlConnection.runAndReadAll(`SELECT table_name, table_type FROM information_schema.tables WHERE ${filters.join(' AND ')} ORDER BY table_name`);
|
|
330
400
|
const tableRows = reader.getRowObjectsJson();
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
401
|
+
return await Promise.all(tableRows.map((row) => this.describeOne(record.controlConnection, row.table_name, row.table_type === 'VIEW' ? 'view' : 'table')));
|
|
402
|
+
}
|
|
403
|
+
async describeOne(connection, tableName, kind) {
|
|
404
|
+
const [colReader, rowCount] = await Promise.all([
|
|
405
|
+
connection.runAndReadAll(`SELECT column_name, data_type, is_nullable FROM information_schema.columns ` +
|
|
335
406
|
`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;
|
|
407
|
+
`ORDER BY ordinal_position`),
|
|
408
|
+
this.countRows(connection, tableName),
|
|
409
|
+
]);
|
|
410
|
+
const colRows = colReader.getRowObjectsJson();
|
|
411
|
+
const columns = colRows.map((c) => ({
|
|
412
|
+
name: c.column_name,
|
|
413
|
+
type: dataTypeToColumnType(c.data_type),
|
|
414
|
+
nullable: c.is_nullable === 'YES',
|
|
415
|
+
}));
|
|
416
|
+
return {
|
|
417
|
+
name: tableName,
|
|
418
|
+
kind,
|
|
419
|
+
rowCount,
|
|
420
|
+
columns,
|
|
421
|
+
};
|
|
352
422
|
}
|
|
353
423
|
async drop(canvasId, name, _context) {
|
|
354
424
|
const record = this.requireCanvas(canvasId);
|
|
355
425
|
assertValidIdentifier(name, 'table');
|
|
356
|
-
const
|
|
357
|
-
if (
|
|
426
|
+
const kind = await this.lookupKind(record.controlConnection, name);
|
|
427
|
+
if (kind === undefined)
|
|
358
428
|
return false;
|
|
359
|
-
|
|
429
|
+
const dropKeyword = kind === 'view' ? 'VIEW' : 'TABLE';
|
|
430
|
+
await record.controlConnection.run(`DROP ${dropKeyword} ${quoteIdentifier(name)}`);
|
|
360
431
|
return true;
|
|
361
432
|
}
|
|
362
433
|
async clear(canvasId, _context) {
|
|
363
434
|
const record = this.requireCanvas(canvasId);
|
|
364
|
-
const reader = await record.controlConnection.runAndReadAll(`SELECT table_name FROM information_schema.tables WHERE table_schema = 'main'`);
|
|
435
|
+
const reader = await record.controlConnection.runAndReadAll(`SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = 'main'`);
|
|
365
436
|
const rows = reader.getRowObjectsJson();
|
|
366
|
-
|
|
367
|
-
|
|
437
|
+
// Drop views before tables so a dependent view doesn't block its base table.
|
|
438
|
+
const ordered = [...rows].sort((a, b) => {
|
|
439
|
+
const aView = a.table_type === 'VIEW';
|
|
440
|
+
const bView = b.table_type === 'VIEW';
|
|
441
|
+
if (aView !== bView)
|
|
442
|
+
return aView ? -1 : 1;
|
|
443
|
+
return a.table_name.localeCompare(b.table_name);
|
|
444
|
+
});
|
|
445
|
+
for (const row of ordered) {
|
|
446
|
+
const dropKeyword = row.table_type === 'VIEW' ? 'VIEW' : 'TABLE';
|
|
447
|
+
await record.controlConnection.run(`DROP ${dropKeyword} ${quoteIdentifier(row.table_name)}`);
|
|
368
448
|
}
|
|
369
449
|
return rows.length;
|
|
370
450
|
}
|
|
@@ -378,26 +458,75 @@ export class DuckdbProvider {
|
|
|
378
458
|
}
|
|
379
459
|
return record;
|
|
380
460
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
461
|
+
/**
|
|
462
|
+
* Run the same four-layer read-only gate `query()` enforces, against an
|
|
463
|
+
* arbitrary SELECT string. Used by `query()` and `registerView()` so view
|
|
464
|
+
* definitions inherit query-level safety.
|
|
465
|
+
*/
|
|
466
|
+
async assertReadOnlySql(record, sql, duck) {
|
|
467
|
+
// Layer 1: text-level deny-list. read_json/read_parquet/... lower into
|
|
468
|
+
// generic scans that pass the operator allowlist, so reject by name first.
|
|
469
|
+
assertNoDeniedFunctions(sql);
|
|
470
|
+
// Layers 2-3: parse and type-check before EXPLAIN.
|
|
471
|
+
const extracted = await record.controlConnection.extractStatements(sql);
|
|
472
|
+
const statementCount = extracted.count;
|
|
473
|
+
let statementType;
|
|
474
|
+
if (statementCount === 1) {
|
|
475
|
+
const prepared = await extracted.prepare(0);
|
|
476
|
+
try {
|
|
477
|
+
statementType = prepared.statementType;
|
|
478
|
+
}
|
|
479
|
+
finally {
|
|
480
|
+
prepared.destroySync();
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
assertSelectOnly({
|
|
484
|
+
statementCount,
|
|
485
|
+
statementType: statementType !== undefined ? (duck.StatementType[statementType] ?? 'UNKNOWN') : 'UNKNOWN',
|
|
486
|
+
});
|
|
487
|
+
// Layer 4: walk the plan with the allowlist + denied-function rescan.
|
|
488
|
+
const planJson = await this.runExplain(record.controlConnection, `EXPLAIN (FORMAT JSON) ${sql}`);
|
|
489
|
+
assertPlanReadOnly(planJson);
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Resolve whether a name on the canvas refers to a base table, a view, or
|
|
493
|
+
* nothing. Returns `undefined` when absent; used by drop/registerView/
|
|
494
|
+
* importFrom to dispatch the right DDL.
|
|
495
|
+
*/
|
|
496
|
+
async lookupKind(connection, name) {
|
|
497
|
+
const reader = await connection.runAndReadAll(`SELECT table_type FROM information_schema.tables ` +
|
|
498
|
+
`WHERE table_schema = 'main' AND table_name = '${escapeSqlString(name)}' LIMIT 1`);
|
|
499
|
+
const rows = reader.getRowObjectsJson();
|
|
500
|
+
if (rows.length === 0)
|
|
501
|
+
return;
|
|
502
|
+
return rows[0]?.table_type === 'VIEW' ? 'view' : 'table';
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Materialize `COUNT(*)` against a (validated) table or view name. DuckDB
|
|
506
|
+
* returns BIGINT as a JSON string; this helper centralizes the `Number(...)`
|
|
507
|
+
* coercion so callers see a plain `number`.
|
|
508
|
+
*/
|
|
509
|
+
async countRows(connection, name) {
|
|
510
|
+
const reader = await connection.runAndReadAll(`SELECT COUNT(*) AS n FROM ${quoteIdentifier(name)}`);
|
|
511
|
+
const row = reader.getRowObjectsJson()[0];
|
|
512
|
+
return Number(row?.n ?? 0);
|
|
385
513
|
}
|
|
386
514
|
async runExplain(connection, explainSql) {
|
|
387
515
|
const reader = await connection.runAndReadAll(explainSql);
|
|
388
516
|
const rows = reader.getRowObjectsJson();
|
|
389
|
-
// EXPLAIN returns
|
|
390
|
-
// the
|
|
517
|
+
// EXPLAIN returns one row with `explain_value` as the JSON tree string.
|
|
518
|
+
// Fail loud if the shape changes — a silent fallback would let queries
|
|
519
|
+
// bypass the plan-walk gate.
|
|
391
520
|
const value = rows[0]?.explain_value;
|
|
392
|
-
if (typeof value
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
521
|
+
if (typeof value !== 'string') {
|
|
522
|
+
throw databaseError('EXPLAIN returned an unexpected shape; canvas plan-walk cannot run safely.', { rowCount: rows.length, hasExplainValue: rows[0]?.explain_value !== undefined });
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
return JSON.parse(value);
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
throw databaseError('Failed to parse EXPLAIN plan JSON.', undefined, { cause: err });
|
|
399
529
|
}
|
|
400
|
-
return rows;
|
|
401
530
|
}
|
|
402
531
|
}
|
|
403
532
|
// ---------------------------------------------------------------------------
|
|
@@ -409,32 +538,10 @@ function buildCreateTableSql(tableName, schema) {
|
|
|
409
538
|
}
|
|
410
539
|
const cols = schema.map((c) => {
|
|
411
540
|
const nullable = c.nullable === false ? ' NOT NULL' : '';
|
|
412
|
-
return `${quoteIdentifier(c.name)} ${
|
|
541
|
+
return `${quoteIdentifier(c.name)} ${c.type}${nullable}`;
|
|
413
542
|
});
|
|
414
543
|
return `CREATE TABLE ${quoteIdentifier(tableName)} (${cols.join(', ')})`;
|
|
415
544
|
}
|
|
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
545
|
/** Map DuckDB `information_schema.columns.data_type` strings back to {@link ColumnType}. */
|
|
439
546
|
function dataTypeToColumnType(dataType) {
|
|
440
547
|
const upper = dataType.toUpperCase();
|
|
@@ -458,71 +565,13 @@ function dataTypeToColumnType(dataType) {
|
|
|
458
565
|
return 'BLOB';
|
|
459
566
|
return 'VARCHAR';
|
|
460
567
|
}
|
|
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) {
|
|
568
|
+
/**
|
|
569
|
+
* Append a value through the DuckDB appender, dispatched by column type.
|
|
570
|
+
* `duck` carries the typed value constructors needed for TIMESTAMP/DATE.
|
|
571
|
+
* Incompatible values fail fast with a structured `validationError` rather
|
|
572
|
+
* than coercing through `String(value)` (which corrupts Dates and binary).
|
|
573
|
+
*/
|
|
574
|
+
function appendValue(appender, col, value, duck) {
|
|
526
575
|
if (value === null || value === undefined) {
|
|
527
576
|
appender.appendNull();
|
|
528
577
|
return;
|
|
@@ -535,7 +584,7 @@ function appendValue(appender, col, value) {
|
|
|
535
584
|
appender.appendInteger(Number(value));
|
|
536
585
|
return;
|
|
537
586
|
case 'BIGINT':
|
|
538
|
-
appender.appendBigInt(
|
|
587
|
+
appender.appendBigInt(toBigInt(value));
|
|
539
588
|
return;
|
|
540
589
|
case 'DOUBLE':
|
|
541
590
|
appender.appendDouble(Number(value));
|
|
@@ -547,22 +596,121 @@ function appendValue(appender, col, value) {
|
|
|
547
596
|
appender.appendVarchar(typeof value === 'string' ? value : JSON.stringify(value));
|
|
548
597
|
return;
|
|
549
598
|
case 'TIMESTAMP':
|
|
599
|
+
appender.appendTimestamp(new duck.DuckDBTimestampValue(toTimestampMicros(value, col.name)));
|
|
600
|
+
return;
|
|
550
601
|
case 'DATE':
|
|
602
|
+
appender.appendDate(new duck.DuckDBDateValue(toDateDays(value, col.name)));
|
|
603
|
+
return;
|
|
551
604
|
case 'BLOB':
|
|
552
|
-
|
|
553
|
-
appender.appendVarchar(String(value));
|
|
605
|
+
appender.appendBlob(toUint8Array(value, col.name));
|
|
554
606
|
return;
|
|
555
607
|
}
|
|
556
608
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
609
|
+
/**
|
|
610
|
+
* Coerce a value to BigInt without precision loss. `BigInt(Number(value))`
|
|
611
|
+
* round-trips through JS Number and truncates outside the 53-bit safe range,
|
|
612
|
+
* silently corrupting BIGINT IDs returned as numeric strings.
|
|
613
|
+
*
|
|
614
|
+
* @internal Exported for unit testing.
|
|
615
|
+
*/
|
|
616
|
+
export function toBigInt(value) {
|
|
617
|
+
if (typeof value === 'bigint')
|
|
618
|
+
return value;
|
|
619
|
+
if (typeof value === 'string' && /^-?\d+$/.test(value))
|
|
620
|
+
return BigInt(value);
|
|
621
|
+
return BigInt(Math.trunc(Number(value)));
|
|
622
|
+
}
|
|
623
|
+
const MS_PER_DAY = 86_400_000;
|
|
624
|
+
/**
|
|
625
|
+
* Coerce to DuckDB's TIMESTAMP unit (micros since 1970-01-01 UTC, as `bigint`).
|
|
626
|
+
* Accepts `Date`, `bigint` (already-micros), `number` (ms-since-epoch matching
|
|
627
|
+
* `Date.getTime()`), and ISO 8601 strings. Throws `validationError` otherwise.
|
|
628
|
+
*
|
|
629
|
+
* @internal Exported for unit testing.
|
|
630
|
+
*/
|
|
631
|
+
export function toTimestampMicros(value, columnName) {
|
|
632
|
+
if (value instanceof Date) {
|
|
633
|
+
const ms = value.getTime();
|
|
634
|
+
if (!Number.isFinite(ms)) {
|
|
635
|
+
throw validationError(`Invalid Date for TIMESTAMP column "${columnName}".`, {
|
|
636
|
+
reason: 'invalid_value_for_type',
|
|
637
|
+
column: columnName,
|
|
638
|
+
type: 'TIMESTAMP',
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
return BigInt(ms) * 1000n;
|
|
642
|
+
}
|
|
643
|
+
if (typeof value === 'bigint')
|
|
644
|
+
return value;
|
|
645
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
646
|
+
return BigInt(Math.trunc(value)) * 1000n;
|
|
647
|
+
}
|
|
648
|
+
if (typeof value === 'string') {
|
|
649
|
+
const ms = Date.parse(value);
|
|
650
|
+
if (Number.isFinite(ms))
|
|
651
|
+
return BigInt(ms) * 1000n;
|
|
652
|
+
}
|
|
653
|
+
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' });
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Coerce to DuckDB's DATE unit (days since 1970-01-01 UTC, as `number`).
|
|
657
|
+
* Accepts the same shapes as {@link toTimestampMicros}; throws otherwise.
|
|
658
|
+
*
|
|
659
|
+
* @internal Exported for unit testing.
|
|
660
|
+
*/
|
|
661
|
+
export function toDateDays(value, columnName) {
|
|
662
|
+
if (value instanceof Date) {
|
|
663
|
+
const ms = value.getTime();
|
|
664
|
+
if (!Number.isFinite(ms)) {
|
|
665
|
+
throw validationError(`Invalid Date for DATE column "${columnName}".`, {
|
|
666
|
+
reason: 'invalid_value_for_type',
|
|
667
|
+
column: columnName,
|
|
668
|
+
type: 'DATE',
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
return Math.floor(ms / MS_PER_DAY);
|
|
672
|
+
}
|
|
673
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
674
|
+
return Math.floor(value / MS_PER_DAY);
|
|
675
|
+
}
|
|
676
|
+
if (typeof value === 'string') {
|
|
677
|
+
const ms = Date.parse(value);
|
|
678
|
+
if (Number.isFinite(ms))
|
|
679
|
+
return Math.floor(ms / MS_PER_DAY);
|
|
565
680
|
}
|
|
681
|
+
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' });
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Coerce to `Uint8Array` for BLOB appends. Accepts `Uint8Array` (Node's
|
|
685
|
+
* `Buffer` passes through as a subclass), `ArrayBuffer`, and any other
|
|
686
|
+
* `ArrayBufferView`. Throws `validationError` for non-binary inputs.
|
|
687
|
+
*
|
|
688
|
+
* @internal Exported for unit testing.
|
|
689
|
+
*/
|
|
690
|
+
export function toUint8Array(value, columnName) {
|
|
691
|
+
if (value instanceof Uint8Array)
|
|
692
|
+
return value;
|
|
693
|
+
if (value instanceof ArrayBuffer)
|
|
694
|
+
return new Uint8Array(value);
|
|
695
|
+
if (ArrayBuffer.isView(value)) {
|
|
696
|
+
const view = value;
|
|
697
|
+
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
698
|
+
}
|
|
699
|
+
throw validationError(`Cannot append ${describeValueType(value)} to BLOB column "${columnName}". Expected Uint8Array, Buffer, or ArrayBuffer.`, { reason: 'invalid_value_for_type', column: columnName, type: 'BLOB' });
|
|
700
|
+
}
|
|
701
|
+
/** Tag describing a runtime value's type, for error messages. */
|
|
702
|
+
function describeValueType(value) {
|
|
703
|
+
if (value === null)
|
|
704
|
+
return 'null';
|
|
705
|
+
if (Array.isArray(value))
|
|
706
|
+
return 'array';
|
|
707
|
+
if (value instanceof Date)
|
|
708
|
+
return 'Date';
|
|
709
|
+
if (value instanceof Uint8Array)
|
|
710
|
+
return 'Uint8Array';
|
|
711
|
+
if (value instanceof ArrayBuffer)
|
|
712
|
+
return 'ArrayBuffer';
|
|
713
|
+
return typeof value;
|
|
566
714
|
}
|
|
567
715
|
/** Escape a string literal for safe inclusion in `'...'` SQL contexts. */
|
|
568
716
|
function escapeSqlString(value) {
|
|
@@ -576,22 +724,16 @@ async function ensureTableMissing(connection, tableName) {
|
|
|
576
724
|
}
|
|
577
725
|
/**
|
|
578
726
|
* Map a DuckDB-thrown error to a framework error class.
|
|
579
|
-
* @internal Exported for unit testing
|
|
727
|
+
* @internal Exported for unit testing.
|
|
580
728
|
*/
|
|
581
729
|
export function classifyDuckdbError(err) {
|
|
582
730
|
if (err instanceof Error) {
|
|
583
731
|
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
732
|
if (/parser error|syntax/i.test(msg)) {
|
|
587
|
-
return validationError(`Canvas SQL rejected: ${msg}`, { reason: 'sql_parse_error' }, {
|
|
588
|
-
cause: err,
|
|
589
|
-
});
|
|
733
|
+
return validationError(`Canvas SQL rejected: ${msg}`, { reason: 'sql_parse_error' }, { cause: err });
|
|
590
734
|
}
|
|
591
735
|
if (/permission|read.?only/i.test(msg)) {
|
|
592
|
-
return validationError(`Canvas SQL rejected: ${msg}`, { reason: 'sql_read_only' }, {
|
|
593
|
-
cause: err,
|
|
594
|
-
});
|
|
736
|
+
return validationError(`Canvas SQL rejected: ${msg}`, { reason: 'sql_read_only' }, { cause: err });
|
|
595
737
|
}
|
|
596
738
|
return databaseError(msg, undefined, { cause: err });
|
|
597
739
|
}
|