@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.
Files changed (55) hide show
  1. package/CLAUDE.md +2 -1
  2. package/README.md +1 -1
  3. package/changelog/0.8.x/0.8.12.md +31 -0
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/index.js +6 -1
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/services/canvas/core/CanvasInstance.d.ts +3 -4
  8. package/dist/services/canvas/core/CanvasInstance.d.ts.map +1 -1
  9. package/dist/services/canvas/core/CanvasInstance.js +3 -4
  10. package/dist/services/canvas/core/CanvasInstance.js.map +1 -1
  11. package/dist/services/canvas/core/CanvasRegistry.d.ts +5 -11
  12. package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
  13. package/dist/services/canvas/core/CanvasRegistry.js +5 -13
  14. package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
  15. package/dist/services/canvas/core/DataCanvas.d.ts +2 -3
  16. package/dist/services/canvas/core/DataCanvas.d.ts.map +1 -1
  17. package/dist/services/canvas/core/DataCanvas.js +3 -8
  18. package/dist/services/canvas/core/DataCanvas.js.map +1 -1
  19. package/dist/services/canvas/core/IDataCanvasProvider.d.ts +4 -4
  20. package/dist/services/canvas/core/IDataCanvasProvider.js +4 -4
  21. package/dist/services/canvas/core/canvasFactory.d.ts +7 -15
  22. package/dist/services/canvas/core/canvasFactory.d.ts.map +1 -1
  23. package/dist/services/canvas/core/canvasFactory.js +7 -16
  24. package/dist/services/canvas/core/canvasFactory.js.map +1 -1
  25. package/dist/services/canvas/core/sqlGate.d.ts +81 -57
  26. package/dist/services/canvas/core/sqlGate.d.ts.map +1 -1
  27. package/dist/services/canvas/core/sqlGate.js +199 -80
  28. package/dist/services/canvas/core/sqlGate.js.map +1 -1
  29. package/dist/services/canvas/index.d.ts +1 -1
  30. package/dist/services/canvas/index.d.ts.map +1 -1
  31. package/dist/services/canvas/index.js +1 -1
  32. package/dist/services/canvas/index.js.map +1 -1
  33. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts +38 -16
  34. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
  35. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +205 -189
  36. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
  37. package/dist/services/canvas/providers/duckdb/exportWriter.d.ts +13 -25
  38. package/dist/services/canvas/providers/duckdb/exportWriter.d.ts.map +1 -1
  39. package/dist/services/canvas/providers/duckdb/exportWriter.js +15 -29
  40. package/dist/services/canvas/providers/duckdb/exportWriter.js.map +1 -1
  41. package/dist/services/canvas/providers/duckdb/schemaSniffer.d.ts +19 -26
  42. package/dist/services/canvas/providers/duckdb/schemaSniffer.d.ts.map +1 -1
  43. package/dist/services/canvas/providers/duckdb/schemaSniffer.js +30 -56
  44. package/dist/services/canvas/providers/duckdb/schemaSniffer.js.map +1 -1
  45. package/dist/services/canvas/types.d.ts +1 -2
  46. package/dist/services/canvas/types.d.ts.map +1 -1
  47. package/dist/services/canvas/types.js +1 -2
  48. package/dist/services/canvas/types.js.map +1 -1
  49. package/dist/utils/internal/requestContext.d.ts +9 -4
  50. package/dist/utils/internal/requestContext.d.ts.map +1 -1
  51. package/dist/utils/internal/requestContext.js.map +1 -1
  52. package/package.json +2 -2
  53. package/dist/logs/combined.log +0 -4
  54. package/dist/logs/error.log +0 -4
  55. package/dist/logs/interactions.log +0 -0
@@ -1,30 +1,23 @@
1
1
  /**
2
- * @fileoverview DuckDB-backed implementation of {@link IDataCanvasProvider}.
3
- * One DuckDB instance per canvasId for memory isolation; a shared connection
4
- * for control-plane work (DDL, describe, drop) and per-query connections for
5
- * data-plane work so that {@link DuckDBConnection.interrupt} cancels exactly
6
- * the in-flight query without disturbing concurrent ops on the same canvas.
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 { databaseError, notFound, validationError } from '../../../../types-global/errors.js';
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 this.getModule();
31
+ const duck = await importDuckDB();
39
32
  const instance = await duck.DuckDBInstance.create(':memory:', {
40
33
  memory_limit: `${this.options.memoryLimitMb}MB`,
41
- // Disable secrets/extensions install paths for safety in canvas mode.
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 this.getModule();
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
- for (const id of ids) {
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
- // Re-create the table the issue's lifecycle guarantees a fresh canvasId
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 (!bufferedRows) {
156
- for (const row of rows) {
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(row);
152
+ appendOne(next.value);
153
+ next = remainingSync.next();
159
154
  }
160
155
  }
161
156
  else {
162
- // Sniffer consumed bufferedRows but the iterable may have more.
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
- // Step 1: parse and type-check before invoking EXPLAIN.
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: statementTypeIdToString(statementType),
194
+ statementType: statementType !== undefined ? (duck.StatementType[statementType] ?? 'UNKNOWN') : 'UNKNOWN',
197
195
  });
198
- // Step 2: walk the plan with the allowlist (defense in depth).
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
- // Step 3: per-query connection so cancellation is scoped to this call.
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
- // interrupt is best-effort; closeSync still cleans up.
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 validationError('Canvas query was cancelled.', { reason: 'cancelled' }, { cause: err });
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
- // Connection may already be torn down by interrupt — ignore.
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
- /* noop */
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: copy to temp file in sandbox, pipe to caller's stream,
298
- // then unlink. tempFilePathFor() creates the sandbox root if missing.
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
- await conn.run(`COPY ${quoteIdentifier(tableName)} TO '${escapeSqlString(tempPath)}' ${formatClause}`);
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
- /* noop */
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
- const result = [];
332
- for (const row of tableRows) {
333
- const tableName = row.table_name;
334
- const colReader = await record.controlConnection.runAndReadAll(`SELECT column_name, data_type, is_nullable FROM information_schema.columns ` +
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
- const colRows = colReader.getRowObjectsJson();
338
- const columns = colRows.map((c) => ({
339
- name: c.column_name,
340
- type: dataTypeToColumnType(c.data_type),
341
- nullable: c.is_nullable === 'YES',
342
- }));
343
- const countReader = await record.controlConnection.runAndReadAll(`SELECT COUNT(*) AS n FROM ${quoteIdentifier(tableName)}`);
344
- const countRow = countReader.getRowObjectsJson()[0];
345
- result.push({
346
- name: tableName,
347
- rowCount: Number(countRow?.n ?? 0),
348
- columns,
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 a single row with an `explain_value` column containing
390
- // the JSON tree as a string.
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 === 'string') {
393
- try {
394
- return JSON.parse(value);
395
- }
396
- catch (err) {
397
- throw databaseError('Failed to parse EXPLAIN plan JSON.', undefined, { cause: err });
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)} ${columnTypeToSql(c.type)}${nullable}`;
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
- /** Convert a DuckDB `StatementType` numeric id to a string the gate can match. */
462
- function statementTypeIdToString(id) {
463
- switch (id) {
464
- case 1:
465
- return 'SELECT';
466
- case 2:
467
- return 'INSERT';
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(typeof value === 'bigint' ? value : BigInt(Math.trunc(Number(value))));
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
- // Use varchar fallback; DuckDB casts on insert when the schema allows.
553
- appender.appendVarchar(String(value));
479
+ appender.appendBlob(toUint8Array(value, col.name));
554
480
  return;
555
481
  }
556
482
  }
557
- function* skipFirst(iter, n) {
558
- let skipped = 0;
559
- for (const item of iter) {
560
- if (skipped < n) {
561
- skipped += 1;
562
- continue;
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
- yield item;
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 — not re-exported from the canvas barrel.
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
  }