Skip to content

@askdb/core API reference

Reference · @askdb/core

@askdb/core is the dialect-agnostic NL-to-SQL pipeline. It owns schema loading, prompt construction, heuristic SQL checks, and tenant-scope validation and binding. Everything else in the AskDB ecosystem composes it.

The main pipeline function. Accepts a natural-language question and a loaded schema; returns checked SQL.

import { ask } from "@askdb/core";
const { sql } = await ask({
question: "How many users signed up last week?",
schema, // from loadSchema()
model, // AI SDK LanguageModel
dialect: "postgres",
});
FieldTypeRequiredDefaultDescription
questionstringyes—The natural-language question.
schemaAnyNormalizedSchemayes—Loaded schema from loadSchema() or loadSchemaFromJson().
modelAskDbLanguageModelyes—An AI SDK LanguageModel instance (e.g. openai("gpt-4o-mini")).
dialectAskDialectInputyes—A built-in dialect id, DialectSpec, or custom AskDialect. See Dialects.
modeAskDbModeV1no"schema_only"Trust boundary. See Modes.
explainbooleannofalseWhen true, populates result.explain with heuristic guardrail metadata.
omitSensitiveIdentifiersFromNlToSqlPromptbooleannofalseWhen true, strips sensitive table/column names from the NL→SQL DDL entirely. Default (false) includes them with a (sensitive) tag so the model can still ground queries.
tenantScopeTenantScopeno—Required when the schema has a tenant-policy.md. The pipeline fails closed if a policy exists and this is absent.
tenantSqlModeTenantSqlOutputModeno"sql-only""sql-only" inlines escaped literal tenant values; "sql-params" replaces them with the dialect’s driver markers ($N Postgres/CockroachDB and custom AskDialects, ? MySQL/MariaDB/SQLite, @pN SQL Server) and returns the IDs as tenantParams. See Executing the result.
resolveTenantDescendantsResolveTenantDescendantsfor subtree scopes—(tenantRoot, seedIds) => TenantIdsByRoot | Promise<TenantIdsByRoot>, where TenantIdsByRoot is Record<rootTableId, string[]>. Returns the subtree’s IDs grouped by tenant root: the seeds and same-table descendants under tenantRoot, each descendant root’s IDs under that root, e.g. { "table:public.agencies": ["1"], "table:public.clients": ["5"] }. ask() unions the seeds in and binds each root’s IDs to its own placeholder (a multi_root scope). Without it, or when it returns a flat array or an invalid result, a subtree scope throws TenantScopeError (SUBTREE_NOT_RESOLVABLE). See Hierarchical scope.
parameterizebooleannotrueAsk the model for unbound SQL + a parameter manifest. Still exactly one model call. Set false to save output tokens. Inert for custom AskDialect implementations.
sensitiveGuardrailMode"warn" | "strict" | "off"no"warn"How to treat SQL that references identifiers marked sensitive. "warn" reports them in result.verdict and result.sensitiveGuardrail and logs; "strict" throws SensitiveReferenceError; "off" skips the check. Any other value refuses a sensitive reference, as "strict" does. See Guardrail verdict and validateSensitiveReferences.
retrieverRetrieverno—RAG retriever from @askdb/rag. When supplied and schema chunk count exceeds retrievalThresholdChunks, retrieval replaces the full DDL in the prompt.
retrievalKnumberno8Top-k passed to the retriever.
retrievalThresholdChunksnumberno30Chunk count below which the full DDL is used even if a retriever is supplied.
totalSchemaChunkCountnumbernoInfinityTotal chunk count from buildSchemaIndex. Pass result.stats.chunksTotal here for the threshold check to be meaningful; omit to always use the retriever when one is supplied.
loggerAskDbLoggerno—Structured logger. Attach one for observability — it emits AskDbLogEvent entries at each pipeline stage.
depsAskGenerateDepsno—{ generateText? } — inject a mock generateText for unit tests.
abortSignalAbortSignalno—Cancels the model call (generateText({ abortSignal })). Use AbortSignal.timeout(ms) for a per-request timeout. With a built-in dialect or DialectSpec, an aborted call rejects with SqlGenerationError whose cause is the abort reason. A custom AskDialect receives it as options.abortSignal and maps its own errors.
FieldTypeWhen present
sqlstringAlways. The model’s bound, checked SQL — business values inlined as literals. Keeps the model’s trailing ;, if it wrote one; remove it before wrapping the statement. In tenantSqlMode: "sql-params" its tenant markers start at the first slot; run it with tenantParams. Never overwritten by rebind failures.
unboundSqlstring | undefinedWhen parameterize is on and the model returned a consistent unbound block + manifest. Driver markers ($N / ? / @pN) for every value; run it with params. Keeps the trailing ; of the model’s sql-unbound block, if it has one.
paramsQueryParamSlot[] | undefinedValues for unboundSql, in marker order. In "sql-params" mode this already includes the tenant IDs.
parametersQueryParameterBinding[] | undefinedNamed bindings for form UIs (includes runtime value, markers, indices).
preparedQueryPreparedQuery | undefinedDefinitions + named template only (no runtime values). Input to bindPreparedQuery().
verdictGuardrailVerdictAlways. Every guardrail finding for the model’s SQL and the outcome: "allow", or "warn" when a check in warn mode found something. A "deny" throws instead. See Guardrail verdict.
usageAskUsage | undefinedWhen the model provider reports token counts.
explainunknown | undefinedWhen explain: true. Heuristic guardrail metadata — which read-only rules fired and why.
sensitiveGuardrailSensitiveGuardrailResult | undefinedWhen the schema declares at least one sensitive table/column and sensitiveGuardrailMode is not "off". The sensitive identifiers the returned SQL references, derived from verdict.
tenantGuardrailTenantGuardrailResult | undefinedWhen a tenant policy is active. { passed, warnings } from the heuristic tenant check, derived from verdict, run on the model’s SQL (and its sql-unbound block) before tenant binding. It requires each tenant-scoped table, and each root table the scope covers, to have a tenant predicate that filters: its tenant column compared with the placeholder, ANDed into a WHERE, inner-join ON or HAVING clause (see What the tenant check requires). Passing it doesn’t prove the SQL is scoped. With enforcement: strict, a failure throws TenantGuardrailError instead.
tenantParamsunknown[] | undefinedWhen tenantSqlMode: "sql-params" and the SQL has tenant placeholders. The tenant IDs for the markers in sql, in marker order.
tenantBindingsTenantBinding[] | undefinedWhen a tenant policy is active. Structured binding metadata for audit.

Each SQL form runs with exactly one params array — never concatenate params and tenantParams:

SQLRun withContents
sqltenantParams (or nothing in "sql-only" mode)Business values inlined; tenant IDs as literals ("sql-only") or markers numbered from the first slot ("sql-params").
unboundSqlparamsAll values bound. In "sql-params" mode tenant IDs are included — after the business values for $N / @pN, interleaved in source order for ?. parameters[].indices point into params. In "sql-only" mode tenant IDs stay inlined and params holds business values only.
const result = await ask({ ...options, dialect: "mysql", tenantScope, tenantSqlMode: "sql-params" });
if (result.unboundSql && result.params) {
await conn.execute(result.unboundSql, result.params);
} else {
await conn.execute(result.sql, result.tenantParams ?? []);
}

Tenant substitution fails closed. It only replaces placeholders in SQL code (never inside string literals or quoted identifiers) and throws TenantScopeError when the SQL references a tenant root the scope has no IDs for (UNRESOLVED_TENANT_PLACEHOLDER), or when several IDs meet a predicate with no list form such as <= (UNSUPPORTED_TENANT_PREDICATE). With several IDs, = becomes IN (…) and != / <> become NOT IN (…). Inlined tenant IDs are escaped for the dialect: when a DialectSpec leaves backslashEscapes unset, a built-in id supplies it (on for MySQL and MariaDB). If the id isn’t built-in, a tenant ID containing a backslash throws UNESCAPABLE_TENANT_ID in "sql-only" mode instead of being escaped by guesswork.

ask() runs up to three checks on the model’s SQL, before it renders tenant IDs or binds values: the read-only check (built-in dialects and DialectSpecs only), the tenant check (with a tenant policy), and the sensitive-identifier check (unless sensitiveGuardrailMode is "off"). Each check reads the model’s bound sql and its sql-unbound block, and one decision maps their findings to an outcome:

Checkask() returnsbindPreparedQuery() rebinds
read-onlyAlways deny. A failing sql-unbound block is dropped with the reuse artifacts instead, and sql is still returned.Always deny.
tenantPolicy enforcement: strict → deny; warn → warn.strict → deny; warn → deny, unless acceptWarnings includes "tenant".
sensitivesensitiveGuardrailMode: "strict" → deny; "warn" → warn.Same as ask().

A deny throws the typed error of the highest-ranked denying check: SqlValidationError, then TenantGuardrailError, then SensitiveReferenceError. Every check runs first, and the error carries the whole verdict as error.verdict.

type GuardrailVerdict = {
outcome: "allow" | "warn" | "deny";
findings: GuardrailFinding[];
};
type GuardrailFinding =
| { check: "read-only"; form: "sql" | "template"; rule: SqlValidationRuleCode; message: string; hint?: string }
| { check: "tenant"; form: "sql" | "template" | "generator"; rule: TenantGuardrailRuleCode; message: string; tableId: string }
| {
check: "sensitive";
form: "sql" | "template";
rule: SensitiveReferenceRuleCode;
message: string;
reference?: SensitiveReference; // SENSITIVE_TABLE_REFERENCED / SENSITIVE_COLUMN_REFERENCED
unresolvedScope?: SensitiveScopeReport; // UNRESOLVED_TABLE_SCOPE
};
type GuardrailOutcome = "allow" | "warn" | "deny";
type GuardrailCheckId = "read-only" | "tenant" | "sensitive";
type GuardrailForm = "sql" | "template";

form says which form the finding is about: "sql" is the model’s bound SQL, "template" is its sql-unbound block (preparedQuery.namedSql), and "generator" is a finding a custom AskDialect reported. generateSelectSql() returns the same verdict for its read-only and tenant checks; it runs no sensitive check. The verdict is in-memory API: AskDB doesn’t store it, so a host that wants to show “had warnings” later stores result.verdict itself. The checks are heuristics: they catch model mistakes early, and the database stays the boundary (see Safety boundaries).

bindPreparedQuery(prepared, values, guardrails)

Section titled “bindPreparedQuery(prepared, values, guardrails)”

Local rebind of a stored preparedQuery with new business values, without a model call. Synchronous. It re-runs the guardrails on the template under the schema, tenant scope and modes you pass now, then renders the template. It returns a ready-to-run sql, unboundSql + params for driver binding, bindings for the business parameters, and the verdict. sql and unboundSql keep the trailing ; of namedSql, if it has one; remove it before you wrap the statement (see Row limits).

import { bindPreparedQuery } from "@askdb/core";
const rebound = bindPreparedQuery(
result.preparedQuery!,
{ state_name: "Utah" },
{ schema, tenantScope }, // the same schema and the caller's scope, as for ask()
);
await pool.query(rebound.sql);
// or: await pool.query(rebound.unboundSql, rebound.params);

The third argument, BindGuardrails, is required:

FieldTypeRequiredDefaultDescription
schemaAnyNormalizedSchemayes—The schema to check the template under: its tenant policy and its sensitive markers.
tenantScopeTenantScopewith a tenant policy—The caller’s scope, as for ask(). Tenant IDs are rendered from it. A subtree scope must be expanded first with expandTenantScope().
sensitiveGuardrailMode"warn" | "strict" | "off"no"warn"As for ask(): "warn" binds and reports sensitive references in verdict; "strict" refuses them.
acceptWarningsReadonlyArray<"tenant">no—With a policy whose enforcement is warn, a template that fails the tenant check is refused unless this includes "tenant".

Tenant IDs come from tenantScope, never from values. Each :tenant_<root>_ids placeholder is filled the way ask() fills it: escaped literals in sql (col = :tenant_agency_ids becomes col IN ('7', '8') with several IDs), and driver markers after the business markers in unboundSql, with the IDs in params (appended for $N / @pN, interleaved in source order for ?, and bindings[].indices remapped).

Every check uses the bind-time inputs, so a template refused today may have bound yesterday: a tightened policy, or a scope other than the one it was produced under, applies at the next rebind. It throws:

  • QueryParameterError (MISSING_GUARDRAIL_CONTEXT) without the third argument or its schema; (TENANT_VALUE_SUPPLIED) for a :tenant_* key in values; (TENANT_PARAM_ALIGNMENT) when the tenant markers can’t be lined up with the business params; and the existing reasons for a wrong, missing or misplaced business value.
  • TenantScopeError: as in ask() for a missing or invalid scope; SUBTREE_NOT_RESOLVABLE for an unexpanded subtree; UNRESOLVED_TENANT_PLACEHOLDER for a placeholder the scope has no IDs for. That includes a template scoped through another root, any placeholder under a global scope, and any placeholder when the schema has no tenant policy.
  • SqlValidationError, TenantGuardrailError or SensitiveReferenceError for a deny verdict (see Guardrail verdict). A refused tenant warning throws TenantGuardrailError with a message naming acceptWarnings.

@askdb/client users call askdb.bind(), which supplies the client’s schema and expands a subtree scope.

List parameters are arity-stable in unboundSql only on PostgreSQL/CockroachDB (= ANY($n)). Elsewhere a changed list length changes the marker count — rebind rather than swapping the array. Map SQL Server values via parameters[].markers (@p0-based).

expandTenantScope(policy, scope, resolveTenantDescendants)

Section titled “expandTenantScope(policy, scope, resolveTenantDescendants)”
import { expandTenantScope } from "@askdb/core";
const expanded = await expandTenantScope(schema.tenantPolicy!, subtreeScope, resolveTenantDescendants);
const rebound = bindPreparedQuery(prepared, values, { schema, tenantScope: expanded });

The subtree expansion ask() runs, as a function: it calls your resolveTenantDescendants and returns a multi_root scope with one entry per root the subtree covers (an ids scope when the subtree is one root table), keeping a level with no IDs as ids: [] so reading it fails closed. Other access kinds pass through. It validates the scope first and throws TenantScopeError (SUBTREE_NOT_RESOLVABLE) for a missing resolver or a malformed result, as ask() does.

type AskUsage = {
promptTokens: number | null;
completionTokens: number | null;
totalTokens: number | null;
};

Populated from the model provider’s usage report. Use for cost tracking or quota enforcement. All fields are null when the provider does not report that counter.


validateSensitiveReferences(sql, schema, options?)

Section titled “validateSensitiveReferences(sql, schema, options?)”

Reports the sensitive tables and columns a SQL statement references. Pure, synchronous, no model call.

sensitive: true otherwise only affects the prompt — formatSchemaForNlToSql either tags identifiers (sensitive) or, with omitSensitiveIdentifiersFromNlToSqlPrompt, withholds them. Both act on what the model sees. This function is the enforcement path: it inspects SQL that is about to execute. Hosts that cache, store, or replay generated SQL should call it on every execution path, not just at generation time.

import { validateSensitiveReferences } from "@askdb/core";
// Cache-replay path — no model in the loop.
const { passed, references } = validateSensitiveReferences(cachedSql, schema);
if (!passed) {
throw new Error(`refusing to replay: ${references.map((r) => r.column).join(", ")}`);
}

ask() runs this check on the model’s SQL and reports it in result.verdict and result.sensitiveGuardrail, and bindPreparedQuery() runs it again on every rebind. Use the standalone function for statements that don’t come through either, such as SQL you store and replay yourself.

FieldTypeMeaning
passedbooleantrue only when nothing sensitive was referenced and table scope was fully resolved.
referencesSensitiveReference[]Every sensitive identifier the statement touches.
unresolvedScopeSensitiveScopeReport | undefinedPresent when the statement’s FROM/JOIN scope could not be resolved with confidence.
type SensitiveReference = {
table: string; // table name as spelled in the schema artifact
schema?: string; // namespace, when the artifact records one
column: string; // column name, or "*" when matchKind is "table"
matchKind: "qualified" | "unqualified" | "table";
};
  • "qualified" — table.column, or alias.column where the alias binds to the owning table. Also alias.* and whole-row references such as row_to_json(alias) or to_jsonb(alias), which reach every sensitive column of that table.
  • "unqualified" — a bare column, counted only because the owning table is in the statement’s scope. Also a bare SELECT *, which reaches every sensitive column of the tables in that SELECT’s own FROM/JOIN (not a subquery’s).
  • "table" — the sensitive table itself is a FROM/JOIN target. Catches SELECT * FROM secrets, where no column is named.
OptionTypeDefaultMeaning
mode"warn" | "strict""warn"See Modes.
dialectDialectSpec (or { id })—Lex the SQL the way that engine does (MySQL backslash escapes and # comments, Postgres E'…' and $tag$…$tag$, SQL Server […]). Without it, references are collected under every built-in engine’s reading, which is conservative and can over-report in rare cases.

An unqualified name is not a substring search. FROM/JOIN targets and their aliases are resolved first — including inside CTEs and derived tables — and bare names are matched only against the columns of tables actually in scope.

This matters most with table-level sensitive, which promotes every column of that table into the match set. A schema with a sensitive audit or changelog table that has an id column would otherwise flag SELECT s.id FROM students s. It does not:

validateSensitiveReferences(
"SELECT s.id, s.first_name FROM people.students s",
schema,
); // → { passed: true, references: [] }
validateSensitiveReferences("SELECT email, password FROM identity.users", schema);
// → references: [{ table: "users", schema: "identity", column: "password", matchKind: "unqualified" }]

{ mode: "warn" } (default) returns the references without throwing. { mode: "strict" } throws SensitiveReferenceError extends AskDbError whenever passed is false, carrying .rule ("SENSITIVE_TABLE_REFERENCED" | "SENSITIVE_COLUMN_REFERENCED" | "UNRESOLVED_TABLE_SCOPE"), .references, and .unresolvedScope.

When the check can tell it couldn’t bind names to tables, it reports that in unresolvedScope rather than passing. It can’t always tell: a table in a FROM form it doesn’t recognize is skipped without a report (see Safety boundaries, #306–#309). unresolvedScope.issues contains one or more of:

IssueCauseEffect
NO_TABLE_SOURCENo FROM/JOIN target resolved, so unqualified names cannot be bound.Unqualified matching widens to every sensitive column.
UNKNOWN_QUALIFIERA qualifier.column whose qualifier is not a known table, alias, or CTE.That column is matched against every sensitive column.
OPAQUE_TABLE_SOURCEA table source that is not a relation name (table function, table-valued expression).Reported only; no widening — such a source exposes no schema columns.
UNTERMINATED_TOKENA string, quoted identifier, or comment never closes.Reported only; the rest of the statement could not be scanned.

unresolvedScope.widened says whether matching was widened, and .message explains why. strict mode treats unresolved scope as a failure: passed is false, so it throws SensitiveReferenceError (rule: "UNRESOLVED_TABLE_SCOPE" when no sensitive reference was found).

The check is heuristic, not a SQL parser — a review and enforcement aid, not a substitute for database-side column privileges.

true when the schema marks at least one table or column sensitive. Cheap pre-check for hosts that want to skip the guardrail entirely on schemas with no markers — ask() uses it for exactly that, which is why result.sensitiveGuardrail is absent on unmarked schemas.

Renders a SensitiveReference as schema.table.column (or table.column when the artifact has no namespace). Used for the CLI warning and the askdb.pipeline.sensitive_sql_warning log field.


import { loadSchema } from "@askdb/core";
const schema = loadSchema("./my-app.schema"); // synchronous

Autodetects and loads a v2 schema artifact. Accepts:

  • A schema directory (<name>.schema/) — the output of askdb introspect
  • A bundled JSON file (*.bundle.json or any JSON with bundled: true) — the output of askdb bundle
  • A direct path to a schema.json file, matched in any case (Schema.json works too). This loads the enclosing directory, so sibling tables/*.md, concepts.md, and tenant-policy.md apply just as they do for the directory path.
  • Any other JSON file, loaded as the physical layer only. If its directory also holds tenant-policy.md, tables/, or concepts.md, loadSchema() throws SchemaParseError instead, because loading the file alone would ignore them: a copy such as prod-schema.json would load without its tenant policy. Pass the directory, or pass the file’s content to loadSchemaFromJson() to load only the physical layer.

Returns NormalizedSchemaV2. Throws SchemaParseError on failure. The optional files (tables/*.md, concepts.md, tenant-policy.md) may be absent. If one exists but is empty or cannot be read or parsed, for example because of malformed YAML front-matter, loading throws. Bundles follow the same rule: only an absent tenantPolicy key means “no policy”, and the error names the file and the bundle, for example tenant-policy.md (in /srv/prod.bundle.json). A broken tenant-policy.md never silently turns tenant enforcement off.

Each table and column in the result has one effective sensitive flag. It is true when schema.json marks it sensitive, when its tables/*.md front-matter sets sensitive: true (which is what Studio’s Sensitivity tab writes), or, for a column, when its table is sensitive. Front-matter is escalate-only: a front-matter sensitive: false never un-marks something that is sensitive anyway. It is ignored and reported in schema.warnings as { kind: "sensitivity_downgrade_ignored", tableFile, id }. A columns entry whose ID belongs to a different table than the file it is in is reported as { kind: "misplaced_column_id", tableFile, id, tableId }. Its sensitive: true still applies to the column the ID names, but nothing else in the entry does. A column ID listed more than once in the same file is reported as { kind: "duplicate_column_id", tableFile, id }. Only the first entry’s description, aliases, and enum are used, but the column is sensitive if any entry naming it, in any tables/*.md file, sets sensitive: true. Two tables/*.md files with the same front-matter id make loadSchema() and loadSchemaFromJson() throw SchemaParseError naming both files. tableFile is always the markdown file that was actually read. Prompt formatting, validateSensitiveReferences, and @askdb/rag chunking all read this effective flag. A column that is sensitive only because its table is also carries sensitiveFromTable: true; @askdb/rag uses it to count a mention of that column only when written as table.column.

loadSchema is synchronous — load once at startup and keep the result in memory, not per request.

import { loadSchemaFromJson } from "@askdb/core";
const schema = loadSchemaFromJson(bundleJsonString);

Parses a raw JSON string. Accepts the same formats as loadSchema (bundled JSON or bare schema.json blob). Useful when the schema is passed as a string over a network boundary (e.g., the HTTP API’s schemaJson request field).

The bundle’s shape is exported as the BundledSchemaV2 type: { bundled: true, physical, tables, concepts?, tenantPolicy? }, where physical is the schema.json object, tables maps each tables/ filename to its raw markdown, and concepts and tenantPolicy hold the raw concepts.md and tenant-policy.md when those files exist. askdb bundle writes it.

findMentionedNames(text, names) and createMentionMatcher(names)

Section titled “findMentionedNames(text, names) and createMentionMatcher(names)”
import { createMentionMatcher, findMentionedNames } from "@askdb/core";
findMentionedNames("Dedupe by EMAIL; see users_email", ["email", "ssn"]); // → ["email"]
const sensitive = createMentionMatcher(["ssn", { table: "users", column: "org_id" }]);
sensitive.mentionsAny('filter by "users"."org_id"'); // → true
sensitive.find("Rows come from users. Org_id is the key"); // → []

The schema contract’s “mentions a sensitive column by name” rule, which @askdb/rag’s chunker and @askdb/enrich’s authoring warning both use: whole-word and case-insensitive, with a bare name (a string, matched literally) or a qualified one ({ table, column }). The full rule is in the schema-v2 contract on GitHub. findMentionedNames returns the names text mentions, in the order given. It compiles the names on every call, so to check many texts against one list, build a MentionMatcher once with createMentionMatcher and call mentionsAny(text) or find(text).


The dialect option accepts three forms:

dialect: "postgres" // or "mysql" | "sqlite" | "sqlserver" | "cockroachdb" | "mariadb"
IdEngine
"postgres"PostgreSQL — " quotes, ILIKE, ::type cast, NOW(), || concat
"cockroachdb"CockroachDB — PostgreSQL-wire-compatible; reuses the Postgres prompt
"mysql"MySQL — ` quotes, CONCAT(), LIKE, CAST(), NOW()
"mariadb"MariaDB — MySQL-protocol-compatible; reuses the MySQL prompt
"sqlite"SQLite — dynamic typing, LIKE, date('now'), || concat, LIMIT
"sqlserver"SQL Server (T-SQL) — [bracket] or " quotes, GETDATE(), +/CONCAT(), CAST()/CONVERT()

The engine adapter packages (@askdb/postgres, @askdb/mysql, etc.) export convenience dialect constants (POSTGRES_DIALECT, MYSQL_DIALECT, …), but passing the string id to ask() is sufficient — you don’t need to install the engine adapter just for SQL generation.

Customize how one of the built-in dialects prompts and validates — for example, a PostgreSQL-compatible warehouse that needs extra guidance in the prompt or extra forbidden keywords. id must be one of the six built-in ids (the spec describes a variant of that engine family, not a brand-new engine — for that, use AskDialect).

import { type DialectSpec } from "@askdb/core";
const redshiftFlavor: DialectSpec = {
id: "postgres", // the built-in family this variant belongs to
displayName: "Amazon Redshift",
promptBrief: "Amazon Redshift SQL. PostgreSQL syntax with some differences: ...",
identifierQuote: '"',
extraForbiddenKeywords: ["COPY", "UNLOAD"],
extraValidate(sql) {
// throw SqlValidationError for dialect-specific rules
},
};
FieldTypeRequiredDescription
idDialectIdyesOne of the six built-in ids — the engine family this spec belongs to.
displayNamestringyesHuman-readable name.
promptBriefstringyesOne-paragraph guidance for the model about this dialect’s syntax.
identifierQuote'"' | '’`yesIdentifier quoting style. The prompt quotes names the way id’s engine does, not with this field.
reservedWordsreadonly string[]noWords the engine reserves, case-insensitive. The prompt lists a schema, table or column with one of these names quoted. The built-in specs set their engine’s list; spread one to keep it, since setting the field replaces the list.
unqualifiedNamespacestringnoA namespace that isn’t a schema in this engine. When it is the schema’s only namespace, the prompt lists its tables without it and tells the model never to write <namespace>.<table>; next to other namespaces (a MySQL database list that includes a database named public) it stays qualified. The SQLite, MySQL and MariaDB specs set SINGLE_NAMESPACE_LABEL ("public"), the namespace their connectors give the database’s tables; spread one to keep it.
extraForbiddenKeywordsreadonly string[]noKeywords to forbid in addition to the shared read-only denylist.
blockedFunctionsreadonly string[]noFunctions validateSelectSql rejects when called (name(), such as pg_sleep or load_file.
extraValidate(sql: string) => voidnoDialect-specific SQL validator. Throw SqlValidationError for failures.
backslashEscapesbooleannoWhether a backslash escapes a quote inside '…' strings on your server. The built-in specs set the engine default (true for MySQL and MariaDB, false otherwise). Set false for MySQL or MariaDB with NO_BACKSLASH_ESCAPES, and true for Postgres with standard_conforming_strings = off. The checks lex strings this way, and bound tenant and parameter values are escaped to match. See Match your server’s string settings.

id also selects how the validator lexes strings, quoted identifiers, and comments. The built-in specs ship their own extraForbiddenKeywords and blockedFunctions; spread one ({ ...POSTGRES_DIALECT, … }) to keep them, since setting either field replaces the built-in list. These checks are defense in depth — run generated SQL under a read-only database role.

id also decides how the prompt quotes names. A schema, table or column whose name is in reservedWords, is a word the SQL checks reject unquoted (the shared denylist plus extraForbiddenKeywords, such as copy), isn’t a plain identifier (on sqlserver, anything beyond ASCII letters, digits, _ and $), or (for postgres and cockroachdb, which fold unquoted names to lowercase) has capitals, is listed quoted (billing."order" for postgres and cockroachdb, billing.`order` for mysql and mariadb, billing.[order] for sqlserver, "order" for sqlite; a Prisma table Post is public."Post" on Postgres). When the prompt lists tables with their schema, it also tells the model to quote each part of a qualified name separately. promptIdentifierQuoter(dialect) returns that quoting as a function; pass it as quoteIdentifier when you call formatSchemaV2ForNlToSql or synthesizeRetrievedDdl yourself.

Full escape hatch for custom NL→SQL generators — agentic flows, tool-calling, fine-tuned models, non-SELECT targets. 95 % of consumers should use a built-in id or DialectSpec instead.

import { type AskDialect } from "@askdb/core";
const myDialect: AskDialect = {
async generate(question, schema, model, options) {
// call your own generation logic
return { sql: "SELECT ...", usage: { promptTokens: 100, completionTokens: 20, totalTokens: 120 } };
},
};

The generate method receives:

  • question: string
  • schema: AnyNormalizedSchema
  • model: AskDbLanguageModel
  • options?: AskDialectGenerateOptions — { logger, explain, omitSensitiveIdentifiersFromNlToSqlPrompt, generateText, providerOptions, abortSignal, prebuiltDdl, tenantPolicy, tenantScope }. Pass abortSignal to your model call so the caller’s timeout cancels it; ask() doesn’t wrap what generate() throws, so an abort surfaces as your dialect’s own error (typically the AI SDK’s AbortError) unless you map it. tenantScope is the scope after ask() expands a subtree: a multi_root scope with one entry per covered root, where a root the resolver returned no IDs for has ids: []. validateTenantScope() accepts it.

It must return AskDialectGenerateResult:

FieldTypeDescription
sqlstringThe generated SQL.
explainunknown | undefinedGuardrail metadata when options.explain is true.
tenantGuardrailTenantGuardrailResult | undefinedOptional extra tenant findings from your generator. With a tenant policy, each warning becomes a tenant finding with form: "generator" in ask()’s verdict, decided under the policy’s enforcement, and passed: false with no warnings becomes one UNPROVABLE_SCOPE finding. They add to ask()’s own check and can’t replace it. Without a tenant policy, it is passed through to result.tenantGuardrail unchanged.
usageAskUsage | undefinedToken usage, if your generator has access to it.

A custom AskDialect is responsible for its own read-only validation. ask() does not run the SELECT-only validator on its output. If your generator should only emit read-only SQL, call validateSelectSql(spec, sql) before returning. Tenant enforcement is different: when the schema has a tenant policy, ask() runs the tenant guardrail on the returned sql (and its sql-unbound block), with the :tenant_<root>_ids placeholders still in place, and then substitutes them, whatever the dialect. Under strict, SQL that fails the tenant check throws TenantGuardrailError; passing it doesn’t prove the SQL is scoped. A custom AskDialect doesn’t tell the guardrail how its engine quotes strings, so the statement must pass under the standard-SQL, Postgres (E'…' escape strings), and MySQL (where "…" is a string) readings: a tenant column written only as "agency_id" doesn’t count as a predicate. The sensitive-identifier check runs on a custom dialect’s SQL too, unless sensitiveGuardrailMode is "off".

Passing an unknown string dialect id throws UnknownDialectError, which extends AskDbError.


AskDbModeV1 controls which post-generation guardrails are active.

ValueDescription
"schema_only" (default)Schema-grounding only. The model sees the DDL; no post-execute paths are activated. Use this for all production read-only queries.
"bounded_results"Reserved for a future post-execute extension (summarizing bounded tabular results through the model). Today it produces exactly the same SQL as schema_only — see Modes and dialects.
import { DEFAULT_ASKDB_MODE, parseAskDbModeV1 } from "@askdb/core";
const mode = parseAskDbModeV1(process.env.ASKDB_MODE); // throws on invalid values

import { createAskDbLogger } from "@askdb/core";
const logger = createAskDbLogger({
correlationId: requestId,
level: "info",
logFile: "./logs/askdb.json",
});

Returns an AskDbLogger that emits structured JSON for each pipeline stage. Pass it to ask() via options.logger.

OptionTypeRequiredDefaultDescription
correlationIdstringyes—Attached to every log line. Pass the request id.
levelAskDbLogLevelno"silent"Pino log level. "silent" suppresses all output.
logFilestringno—Append JSON logs to this path. Parent directories are created automatically.
logStdoutbooleannofalseDuplicate logs to stdout in addition to stderr/file.

AskDbLogLevel: "fatal" | "error" | "warn" | "info" | "debug" | "trace" | "silent"

The emitted log events are typed as AskDbLogEvent constants (e.g. PipelineMode, PipelineGenerateStart, TenantScopeValidated). Import AskDbLogEvent from @askdb/core to filter logs by event kind.


These types are needed when your schema has a tenant policy. See the multi-tenancy guide for the full workflow.

  • "sql-only" — inlines literal tenant values directly in the SQL string
  • "sql-params" — converts tenant values to positional $N parameters; use with parameterized queries
type TenantBinding = {
placeholder: string; // e.g. ":tenant_org_ids"
rootLabel: string; // domain label (e.g. "organization")
rootId: string; // tenant root id
ids: string[]; // resolved id values
};
import { placeholderForTenantRoot } from "@askdb/core";
placeholderForTenantRoot({ id: "table:public.sub_agencies", label: "Sub-Agency" }); // ":tenant_sub_agency_ids"
placeholderForTenantRoot({ id: "table:public.clients", label: "Клиент" }); // ":tenant_clients_ids"

Returns the :tenant_<name>_ids placeholder for a tenant root: the one the prompt names, ask() and resolveTenantSql() bind, and the tenant guardrail checks. <name> is the label lowercased, with every run of characters other than ASCII letters and digits replaced by _. A label with no ASCII letter or digit uses the root’s table name instead. Pass a root from schema.tenantPolicy.roots (anything with id and label).

placeholderForRoot(label) is deprecated. It takes the label alone, so for a label with no ASCII letter or digit it returns :tenant___ids, which core doesn’t bind. For every other label the two agree.

Passed as options.tenantScope to ask(). Carries enforceable access plus optional advisory context.

// IDs access — user can see records for their specific org ids
const scope: TenantScope = {
access: {
kind: "ids",
tenantRoot: "table:public.organizations", // stable table ID from the policy
ids: ["org-1", "org-2"],
},
context: { role: "regional_manager", label: "Jane Smith" },
};
// Global / super access
const adminScope: TenantScope = {
access: { kind: "global", reason: "support investigation" },
};

Access kinds:

KindDescription
"ids"User can access specific root-entity ids.
"subtree"User can access a subtree rooted at specific ids, including descendant roots. Requires the resolveTenantDescendants option on ask(), which returns IDs per root; ask() expands the scope to "multi_root" before generation.
"multi_root"User has access across multiple tenant roots, each with their own id set. An entry’s ids may be empty (a covered root with no IDs, which fails closed if read); at least one entry needs an ID.
"global"Super / admin access. Requires a reason string for audit. Binds no tenant IDs: SQL that still has a :tenant_* placeholder throws TenantScopeError (UNRESOLVED_TENANT_PLACEHOLDER), in ask() and at rebind.