Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/core-as-peer-dependency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@asksql/duckdb': minor
'@asksql/mcp': minor
'@asksql/mongodb': minor
'@asksql/mysql': minor
'@asksql/oracle': minor
'@asksql/postgres': minor
'@asksql/react': minor
'@asksql/server': minor
'@asksql/sqlite': minor
---

Depend on `@asksql/core` as a peer rather than a regular dependency. As a regular dependency, a
consumer pinned to a different core minor got a second copy of core installed under the connector
instead of a resolution error. Structural types survive that; identity does not, so
`error instanceof AskSqlError` was false for every error the connector threw and consumer error
handling silently stopped matching. The peer range is `>=0.6.0`, so npm and pnpm install one shared
core and report a real conflict when the consumer's pin cannot satisfy it.

Yarn (classic and berry) and npm with `legacy-peer-deps` do not install peers, so on those
`@asksql/core` must now be installed explicitly alongside the package.
20 changes: 20 additions & 0 deletions .changeset/mongo-row-cap-and-grounding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@asksql/core': patch
'@asksql/react': minor
---

Clamp the MongoDB row cap the way the SQL side already did. A `maxRows` that was fractional, zero or
negative was passed straight into `$limit`, which MongoDB rejects outright, so the query failed
rather than returning fewer rows; a value above the engine's ceiling was injected unclamped while
the surrounding warning text named the capped number. Both engines now resolve the cap through one
shared function, so the prompt, the injected limit and the warning always name the same figure.

Stop reporting a backticked placeholder as a name missing from your schema. Backticks wrap more than
identifiers, so `` `?` ``, a date, or `:param` were each reported as a table or column that does not
exist. Hyphenated names, which are legal inside backticks, are still checked.

React: copy controls on explanations, schema answers, the query plan and the result grid; the
model's output is shown as it streams; the thread only follows new content when you are already at
the bottom; a schema answer no longer renders a red error while it is still being written; truncated
cells carry their full value; and `maxRows` takes effect on the next question rather than when the
connection changes.
10 changes: 6 additions & 4 deletions docs/FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ new DuckDbConnector({ id: 'book', name: 'Workbook', files: [

### What file types and sizes can it handle?

Five formats: **CSV**, **JSON**, **NDJSON**, **Parquet**, and **Excel** (`.xlsx` / `.xls`). The
format is inferred from the extension, or you can set `format` explicitly. You can register as
many files as you like - there is no file-count limit, and each becomes its own joinable table.
Six formats: **CSV**, **JSON**, **NDJSON**, **Parquet**, **Excel** (`.xlsx` / `.xls`), and a
portable **`.sql`** dump (its CREATE TABLE + INSERT statements are run and the tables they build
become queryable). The format is inferred from the extension, or you can set `format` explicitly.
You can register as many files as you like - there is no file-count limit, and each becomes its
own joinable table.

There is **no fixed size cap** in AskSQL itself. In the browser the file is streamed into
DuckDB-WASM (bounded by the tab's available memory, or persistent OPFS storage if enabled), and
Expand Down Expand Up @@ -251,7 +253,7 @@ entirely. The guard still enforces read-only regardless of what any prompt says.

### Is it production-ready?

It is an early (pre-1.0; `@asksql/core` is at `0.5.x`) but functional release: the pipeline
It is an early (pre-1.0; `@asksql/core` is at `0.6.x`) but functional release: the pipeline
(schema to SQL to guard to execute), the safety guard, the six database adapters, the server
sidecar, the React UI, and the MCP server are all working and tested against live databases
and multiple providers. Treat
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/grounding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,9 @@ export interface GroundingOptions {
readonly documentStyle?: boolean;
}

/** An identifier, optionally schema-qualified. Placeholders, literals and operators do not match. */
const IDENTIFIER_SHAPE = /^[a-z_][a-z0-9_$-]*(?:\.[a-z_][a-z0-9_$-]*)*$/i;

export function unknownReferencesInProse(
answer: string,
catalog: SchemaCatalog,
Expand All @@ -254,6 +257,8 @@ export function unknownReferencesInProse(
let m: RegExpExecArray | null;
while ((m = re.exec(scanned)) !== null) {
if (opts.documentStyle && m[2]) continue; // "shipped" is a value, not an identifier
// Backticks wrap anything, so a placeholder or a literal can arrive here.
if (m[1] !== undefined && !IDENTIFIER_SHAPE.test(m[1])) continue;
const raw = (m[1] ?? m[2] ?? m[3] ?? '').toLowerCase();
if (raw.startsWith('$')) continue; // $lookup / $group are operators
// Backticked SQL vocabulary is not a name claim; a call with parentheses is a function.
Expand Down
10 changes: 2 additions & 8 deletions packages/core/src/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import pkg from 'node-sql-parser';
import { AskSqlError } from './errors.js';
import { clampMaxRows } from './row-cap.js';
import { hasMultipleStatements, maskCommentsAndStrings, stripCommentsAndStrings, trimTrailingNoise } from './strip.js';
import type { DialectInfo, EngineKind, GuardPolicy, GuardVerdict } from './types.js';

Expand Down Expand Up @@ -774,9 +775,6 @@ export interface GuardInput {
readonly policy?: Partial<GuardPolicy>;
}

/** Nothing a caller asks for may exceed this; a row cap is a memory bound, not a preference. */
const MAX_ROW_CAP = 100_000;

export function resolveGuardPolicy(partial?: Partial<GuardPolicy>): GuardPolicy {
const merged: { -readonly [K in keyof GuardPolicy]: GuardPolicy[K] } = {
...DEFAULT_GUARD_POLICY,
Expand All @@ -786,11 +784,7 @@ export function resolveGuardPolicy(partial?: Partial<GuardPolicy>): GuardPolicy
};
// maxRows reaches here straight from an HTTP client, so it is clamped rather than trusted:
// a NaN or a billion would otherwise become the row cap.
const requested = merged.maxRows;
merged.maxRows =
Number.isFinite(requested) && requested >= 1
? Math.min(Math.floor(requested), MAX_ROW_CAP)
: DEFAULT_GUARD_POLICY.maxRows;
merged.maxRows = clampMaxRows(merged.maxRows, DEFAULT_GUARD_POLICY.maxRows);
if ((partial as { mode?: string } | undefined)?.mode && partial?.mode !== 'read-only') {
throw new AskSqlError('CONFIG_ERROR', {
detail: `GuardPolicy.mode '${String(partial?.mode)}' is not supported - the read-only floor is immovable in v1.`,
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/mongo/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ import type {
SchemaCatalog,
} from '../types.js';
import {
DEFAULT_MONGO_GUARD_POLICY,
guardPipeline,
parsePipeline,
resolveMongoGuardPolicy,
type MongoGuardPolicy,
type MongoGuardVerdict,
} from './guard.js';
Expand Down Expand Up @@ -204,7 +204,8 @@ function isNoOpPipeline(pipelineJson: string): boolean {
}

export function createMongoAskSql(config: MongoAskConfig): MongoAskEngine {
const policy: MongoGuardPolicy = { ...DEFAULT_MONGO_GUARD_POLICY, ...config.policy };
// The prompt, the guard and the warning text all name one row cap.
const policy: MongoGuardPolicy = resolveMongoGuardPolicy(config.policy);

let cached: { catalog: SchemaCatalog; at: number; ttl: number } | null = null;
let inflight: Promise<SchemaCatalog> | null = null;
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/mongo/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* $limit injected or lowered to the row cap. Fail-closed.
*/

import { clampMaxRows } from '../row-cap.js';

export interface MongoGuardPolicy {
readonly maxRows: number;
readonly maxDepth: number;
Expand All @@ -17,6 +19,12 @@ export const DEFAULT_MONGO_GUARD_POLICY: MongoGuardPolicy = Object.freeze({
maxRegexPatternLength: 200,
});

/** MongoDB rejects a non-integer or non-positive $limit. */
export function resolveMongoGuardPolicy(partial?: Partial<MongoGuardPolicy>): MongoGuardPolicy {
const merged = { ...DEFAULT_MONGO_GUARD_POLICY, ...partial };
return { ...merged, maxRows: clampMaxRows(merged.maxRows, DEFAULT_MONGO_GUARD_POLICY.maxRows) };
}

export interface MongoGuardVerdict {
readonly allowed: boolean;
/** The re-serialized, capped pipeline as a bare JSON array string. Meaningful only when allowed. */
Expand Down Expand Up @@ -407,7 +415,11 @@ export function guardPipeline(
if (walk.violation) return blocked(walk.violation.ruleId, walk.violation.reason);

const capped = [...pipeline];
const { autoLimited, loweredLimit } = capPipeline(capped, policy.maxRows);
// guardPipeline is public, so a direct caller's policy is clamped here too.
const { autoLimited, loweredLimit } = capPipeline(
capped,
clampMaxRows(policy.maxRows, DEFAULT_MONGO_GUARD_POLICY.maxRows),
);

return {
allowed: true,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/mongo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
DEFAULT_MONGO_GUARD_POLICY,
guardPipeline,
parsePipeline,
resolveMongoGuardPolicy,
type MongoGuardPolicy,
type MongoGuardVerdict,
} from './guard.js';
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/row-cap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* The one row-cap clamp, shared by the SQL and MongoDB guards. maxRows arrives untrusted, from a
* user setting or an HTTP client.
*/

/** Nothing a caller asks for may exceed this; a row cap is a memory bound, not a preference. */
export const MAX_ROW_CAP = 100_000;

/** Always a positive integer: `fallback` unless `requested` is finite and >= 1, then floored and capped. */
export function clampMaxRows(requested: number | undefined, fallback: number): number {
return typeof requested === 'number' && Number.isFinite(requested) && requested >= 1
? Math.min(Math.floor(requested), MAX_ROW_CAP)
: fallback;
}
113 changes: 113 additions & 0 deletions packages/core/test/mongo-row-cap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/** MongoDB rejects a $limit that is not a positive integer. */
import { describe, expect, it, vi } from 'vitest';
import { createMongoAskSql, guardPipeline, resolveMongoGuardPolicy, type MongoConnector } from '../src/mongo/index.js';
import type { CustomModel, ExecuteOptions, ResultSet, SchemaCatalog } from '../src/types.js';

const CATALOG: SchemaCatalog = {
engine: 'mongodb',
schemas: ['shop'],
tables: [
{
name: 'orders',
kind: 'table',
columns: [
{ name: '_id', dbType: 'objectId', nullable: false },
{ name: 'status', dbType: 'string', nullable: true },
],
primaryKey: ['_id'],
foreignKeys: [],
uniques: [],
checks: [],
indexes: [],
},
],
enums: [],
sequences: [],
triggers: [],
routines: [],
warnings: [],
fetchedAt: 'now',
};

const RESULT: ResultSet = { columns: [], rows: [], rowCount: 0, truncated: false, durationMs: 1, warnings: [] };

class FakeMongo implements MongoConnector {
readonly id = 'm';
readonly name = 'Shop Mongo';
readonly engine = 'mongodb' as const;
readonly database = 'shop';
connect = vi.fn(async () => {});
close = vi.fn(async () => {});
async introspect(): Promise<SchemaCatalog> {
return CATALOG;
}
async aggregate(_c: string, _p: unknown[], _o?: ExecuteOptions): Promise<ResultSet> {
return RESULT;
}
}

const model =
(reply: string): CustomModel =>
async () =>
reply;

/** `requested` as configured -> the only $limit MongoDB may legally be sent. */
const CASES: { label: string; requested: number | undefined; expected: number }[] = [
{ label: 'fractional 12.5 floors to an integer', requested: 12.5, expected: 12 },
{ label: 'zero falls back to the default', requested: 0, expected: 1000 },
{ label: 'negative falls back to the default', requested: -5, expected: 1000 },
{ label: 'absurd 200000 is capped', requested: 200_000, expected: 100_000 },
{ label: 'missing value falls back to the default', requested: undefined, expected: 1000 },
{ label: 'NaN falls back to the default', requested: Number.NaN, expected: 1000 },
{ label: 'Infinity falls back to the default', requested: Number.POSITIVE_INFINITY, expected: 1000 },
];

const lastLimit = (pipelineJson: string): unknown => {
const stages = JSON.parse(pipelineJson) as Record<string, unknown>[];
return stages[stages.length - 1]?.['$limit'];
};

describe('mongo row cap clamp', () => {
for (const { label, requested, expected } of CASES) {
it(`resolveMongoGuardPolicy: ${label}`, () => {
const policy = resolveMongoGuardPolicy(requested === undefined ? {} : { maxRows: requested });
expect(policy.maxRows).toBe(expected);
expect(Number.isInteger(policy.maxRows)).toBe(true);
expect(policy.maxRows).toBeGreaterThan(0);
});

it(`guardPipeline injects the clamped $limit: ${label}`, () => {
const policy = resolveMongoGuardPolicy({ maxDepth: 400, maxRegexPatternLength: 200 });
const v = guardPipeline('[{"$match":{}}]', {
...policy,
...(requested === undefined ? {} : { maxRows: requested }),
});
expect(v.allowed).toBe(true);
const limit = lastLimit(v.pipelineJson);
expect(limit).toBe(expected);
expect(Number.isInteger(limit)).toBe(true);
expect(limit as number).toBeGreaterThan(0);
});

it(`engine ask injects the clamped $limit and names it in the warning: ${label}`, async () => {
const engine = createMongoAskSql({
connector: new FakeMongo(),
model: model('```js\ndb.orders.aggregate([{"$match": {"status": "paid"}}])\n```\nPaid orders.'),
...(requested === undefined ? {} : { policy: { maxRows: requested } }),
});
const res = await engine.ask('paid orders');
const limit = lastLimit(res.pipelineJson);
expect(limit).toBe(expected);
expect(Number.isInteger(limit)).toBe(true);
expect(limit as number).toBeGreaterThan(0);
expect(res.autoLimited).toBe(true);
expect(res.warnings.join(' ')).toContain(`A row limit of ${expected} was added automatically`);
});
}

it('an over-large trailing $limit is lowered to the clamped cap, not the raw setting', () => {
const v = guardPipeline('[{"$match":{}},{"$limit":9999999}]', { ...resolveMongoGuardPolicy({}), maxRows: 200_000 });
expect(v.loweredLimit).toBe(true);
expect(lastLimit(v.pipelineJson)).toBe(100_000);
});
});
26 changes: 26 additions & 0 deletions packages/core/test/scope-grounding-edges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,32 @@ describe('the grounding floor is not disarmed by the English word "with"', () =>
});
});

describe('backticks wrap more than identifiers', () => {
it('does not report a bound-parameter placeholder as a missing name', () => {
const answer = 'Bind the id as `?` and pass it yourself.';
expect(unknownReferencesInProse(answer, CATALOG)).toEqual([]);
});

it('does not report a backticked literal or operator as a missing name', () => {
for (const answer of ['Use `2024-01-01` as the cutoff.', 'Compare with `>=` on the date.', 'Pass `:customer_id`.']) {
expect(unknownReferencesInProse(answer, CATALOG)).toEqual([]);
}
});

it('still reports a backticked name that really is missing', () => {
expect(unknownReferencesInProse('Add a `customer_history` table.', CATALOG)).toContain('customer_history');
});

it('still accepts a backticked qualified name that exists', () => {
expect(unknownReferencesInProse('Read `shop.orders` for this.', CATALOG)).toEqual([]);
});

// Backticks are how MySQL quotes identifiers, and a hyphen is legal inside them.
it('still reports a missing hyphenated name', () => {
expect(unknownReferencesInProse('Check the `order-history` table.', CATALOG)).toContain('order-history');
});
});

describe('SQL vocabulary in an answer is not an invented name', () => {
// An answer that sets keywords in backticks - the normal way to write one - reported them as
// invented names, costing a repair round-trip and marking it ungrounded.
Expand Down
10 changes: 6 additions & 4 deletions packages/duckdb/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
# @asksql/duckdb

The DuckDB connector for [AskSQL](https://github.com/rahulmahadik/AskSQL): local analytics over
CSV / JSON / NDJSON / Parquet / Excel files or a DuckDB database file, with no backend. Two
entry points share one implementation:
CSV / JSON / NDJSON / Parquet / Excel files, a portable `.sql` dump (CREATE TABLE + INSERT), or a
DuckDB database file, with no backend. Two entry points share one implementation:

- `@asksql/duckdb` (Node), on `@duckdb/node-api`. Also loads a portable `.sql` dump
(CREATE TABLE + INSERT).
- `@asksql/duckdb` (Node), on `@duckdb/node-api`.
- `@asksql/duckdb/browser`, on `@duckdb/duckdb-wasm`, in a Web Worker with optional
OPFS persistence. Data never leaves the tab.

Expand All @@ -16,6 +15,9 @@ npm i @asksql/core @asksql/duckdb @duckdb/node-api # Node
npm i @asksql/core @asksql/duckdb @duckdb/duckdb-wasm # browser
```

`@asksql/core` is a peer dependency, and yarn (or npm with `legacy-peer-deps`) will not install it
for you, so name it explicitly as above.

## Node

```ts
Expand Down
5 changes: 2 additions & 3 deletions packages/duckdb/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,8 @@
"scripts": {
"build": "tsc -b"
},
"dependencies": {
"@asksql/core": "workspace:^"
},
"peerDependencies": {
"@asksql/core": "workspace:>=0.6.0",
"@duckdb/duckdb-wasm": ">=1.28",
"@duckdb/node-api": ">=1.4.0-r.1"
},
Expand All @@ -38,6 +36,7 @@
}
},
"devDependencies": {
"@asksql/core": "workspace:>=0.6.0",
"@duckdb/duckdb-wasm": "^1.32.0",
"@duckdb/node-api": "1.5.4-r.1"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ MongoDB uses the separate `createMongoAskSql` engine and is not exposed over MCP
npm i @asksql/core @asksql/mcp @modelcontextprotocol/sdk
```

`@asksql/core` is a peer dependency, and yarn (or npm with `legacy-peer-deps`) will not install it
for you, so name it explicitly as above.

## Setting it up in an MCP host

An MCP host launches your server as a subprocess and talks to it over stdin/stdout, so you
Expand Down
Loading