@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.
Files changed (57) 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/changelog/0.8.x/0.8.13.md +27 -0
  5. package/dist/config/index.d.ts.map +1 -1
  6. package/dist/config/index.js +6 -1
  7. package/dist/config/index.js.map +1 -1
  8. package/dist/logs/combined.log +4 -4
  9. package/dist/logs/error.log +4 -4
  10. package/dist/services/canvas/core/CanvasInstance.d.ts +16 -5
  11. package/dist/services/canvas/core/CanvasInstance.d.ts.map +1 -1
  12. package/dist/services/canvas/core/CanvasInstance.js +22 -4
  13. package/dist/services/canvas/core/CanvasInstance.js.map +1 -1
  14. package/dist/services/canvas/core/CanvasRegistry.d.ts +5 -11
  15. package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
  16. package/dist/services/canvas/core/CanvasRegistry.js +5 -13
  17. package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
  18. package/dist/services/canvas/core/DataCanvas.d.ts +2 -3
  19. package/dist/services/canvas/core/DataCanvas.d.ts.map +1 -1
  20. package/dist/services/canvas/core/DataCanvas.js +3 -8
  21. package/dist/services/canvas/core/DataCanvas.js.map +1 -1
  22. package/dist/services/canvas/core/IDataCanvasProvider.d.ts +24 -6
  23. package/dist/services/canvas/core/IDataCanvasProvider.d.ts.map +1 -1
  24. package/dist/services/canvas/core/IDataCanvasProvider.js +4 -4
  25. package/dist/services/canvas/core/canvasFactory.d.ts +7 -15
  26. package/dist/services/canvas/core/canvasFactory.d.ts.map +1 -1
  27. package/dist/services/canvas/core/canvasFactory.js +7 -16
  28. package/dist/services/canvas/core/canvasFactory.js.map +1 -1
  29. package/dist/services/canvas/core/sqlGate.d.ts +90 -60
  30. package/dist/services/canvas/core/sqlGate.d.ts.map +1 -1
  31. package/dist/services/canvas/core/sqlGate.js +231 -84
  32. package/dist/services/canvas/core/sqlGate.js.map +1 -1
  33. package/dist/services/canvas/index.d.ts +2 -2
  34. package/dist/services/canvas/index.d.ts.map +1 -1
  35. package/dist/services/canvas/index.js +1 -1
  36. package/dist/services/canvas/index.js.map +1 -1
  37. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts +59 -17
  38. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
  39. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +364 -222
  40. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
  41. package/dist/services/canvas/providers/duckdb/exportWriter.d.ts +13 -25
  42. package/dist/services/canvas/providers/duckdb/exportWriter.d.ts.map +1 -1
  43. package/dist/services/canvas/providers/duckdb/exportWriter.js +15 -29
  44. package/dist/services/canvas/providers/duckdb/exportWriter.js.map +1 -1
  45. package/dist/services/canvas/providers/duckdb/schemaSniffer.d.ts +19 -26
  46. package/dist/services/canvas/providers/duckdb/schemaSniffer.d.ts.map +1 -1
  47. package/dist/services/canvas/providers/duckdb/schemaSniffer.js +30 -56
  48. package/dist/services/canvas/providers/duckdb/schemaSniffer.js.map +1 -1
  49. package/dist/services/canvas/types.d.ts +33 -6
  50. package/dist/services/canvas/types.d.ts.map +1 -1
  51. package/dist/services/canvas/types.js +1 -2
  52. package/dist/services/canvas/types.js.map +1 -1
  53. package/dist/utils/internal/requestContext.d.ts +9 -4
  54. package/dist/utils/internal/requestContext.d.ts.map +1 -1
  55. package/dist/utils/internal/requestContext.js.map +1 -1
  56. package/package.json +2 -2
  57. package/skills/api-canvas/SKILL.md +42 -8
@@ -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,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
- // Step 1: parse and type-check before invoking EXPLAIN.
182
- const extracted = await record.controlConnection.extractStatements(sql);
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
- // interrupt is best-effort; closeSync still cleans up.
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
- const countReader = await conn.runAndReadAll(`SELECT COUNT(*) AS n FROM ${quoteIdentifier(options.registerAs)}`);
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 validationError('Canvas query was cancelled.', { reason: 'cancelled' }, { cause: err });
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
- // Connection may already be torn down by interrupt — ignore.
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
- /* noop */
251
+ /* interrupt is best-effort. */
278
252
  }
279
253
  };
280
254
  options?.signal?.addEventListener('abort', onAbort, { once: true });
281
255
  try {
282
- // Row count from the table — cheap, used for the result payload.
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: copy to temp file in sandbox, pipe to caller's stream,
298
- // then unlink. tempFilePathFor() creates the sandbox root if missing.
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
- await conn.run(`COPY ${quoteIdentifier(tableName)} TO '${escapeSqlString(tempPath)}' ${formatClause}`);
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
- /* noop */
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 filter = options?.tableName
327
- ? ` AND table_name = '${escapeSqlString(options.tableName)}'`
328
- : '';
329
- const reader = await record.controlConnection.runAndReadAll(`SELECT table_name FROM information_schema.tables WHERE table_schema = 'main'${filter} ORDER BY table_name`);
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
- 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 ` +
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
- 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;
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 checkReader = await record.controlConnection.runAndReadAll(`SELECT 1 FROM information_schema.tables WHERE table_schema = 'main' AND table_name = '${escapeSqlString(name)}' LIMIT 1`);
357
- if (checkReader.getRowsJson().length === 0)
426
+ const kind = await this.lookupKind(record.controlConnection, name);
427
+ if (kind === undefined)
358
428
  return false;
359
- await record.controlConnection.run(`DROP TABLE ${quoteIdentifier(name)}`);
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
- for (const row of rows) {
367
- await record.controlConnection.run(`DROP TABLE ${quoteIdentifier(row.table_name)}`);
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
- async getModule() {
382
- if (!this.duck)
383
- this.duck = await importDuckDB();
384
- return this.duck;
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 a single row with an `explain_value` column containing
390
- // the JSON tree as a string.
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 === '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
- }
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)} ${columnTypeToSql(c.type)}${nullable}`;
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
- /** 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) {
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(typeof value === 'bigint' ? value : BigInt(Math.trunc(Number(value))));
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
- // Use varchar fallback; DuckDB casts on insert when the schema allows.
553
- appender.appendVarchar(String(value));
605
+ appender.appendBlob(toUint8Array(value, col.name));
554
606
  return;
555
607
  }
556
608
  }
557
- function* skipFirst(iter, n) {
558
- let skipped = 0;
559
- for (const item of iter) {
560
- if (skipped < n) {
561
- skipped += 1;
562
- continue;
563
- }
564
- yield item;
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 — not re-exported from the canvas barrel.
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
  }