Skip to content

Upgrade notes by release

Reference · Releases

What each AskDB release asks of your app, newest first. Each section lists only the changes that need something from you, and the package CHANGELOGs list everything else. Upgrade AskDB says how to apply them.

Released with askdb, @askdb/ai, @askdb/client, @askdb/config, @askdb/core, @askdb/http-api, @askdb/introspect, @askdb/mysql, @askdb/postgres, @askdb/prisma, @askdb/sqlite and @askdb/sqlserver 1.0.0-rc.1, the first release candidate for 1.0, and @askdb/studio, @askdb/enrich and @askdb/rag 0.2.0-rc.1. The package CHANGELOGs list everything else that changed.

The 12 packages on the 1.0 line now share one version, and every release publishes all 12 at that version, even a package that didn’t change (#354). Seven of them jump from 0.x:

PackageBeforeFrom this release
@askdb/ai0.1.0-beta.*1.0.0-rc.1
@askdb/introspect0.3.0-beta.*1.0.0-rc.1
@askdb/mysql0.1.0-beta.*1.0.0-rc.1
@askdb/postgres0.2.0-beta.*1.0.0-rc.1
@askdb/prisma0.2.0-beta.*1.0.0-rc.1
@askdb/sqlite0.1.0-beta.*1.0.0-rc.1
@askdb/sqlserver0.1.0-beta.*1.0.0-rc.1

A range written for a 0.x version doesn’t match 1.0.0-rc.1. With "@askdb/ai": "^0.1.0-beta.9" in your package.json, npm update stops at the newest 0.1.0-beta.* and never installs 1.0.0-rc.1. Install every AskDB package your app lists at @latest in one step, as step 3 of Before you upgrade shows. That also moves the ranges in your package.json to the 1.0 line.

  • From 1.0.0 on, a breaking change to any of the 12 comes only in a new major version, of all 12 together. The exception is the experimental surfaces of the 1.0 packages, which can change in a minor version: the studio and rag blocks of defineConfig(), the askdb studio, enrich, bundle and rag commands, askdb init’s RAG and Studio flags, the @askdb/config/scaffold entry point, and the @askdb/client/charts entry point.
  • 1.0.0-rc.1 freezes the public API of the 12. Later release candidates fix bugs. When a fix asks something of your app, it gets its own section on this page. The schema artifact format is the exception: Schema v2 can still change in a release candidate before 1.0.0, and such a change gets its own section here too.
  • Studio, @askdb/enrich and @askdb/rag stay on 0.x, because their APIs can still change. A breaking change to one of them comes in a minor version, such as 0.3.0, and gets its own section here.

Versioning and stability lists both sets, the experimental surfaces and the dist-tags.

Separate keywords, names and numbers with ASCII spaces under sqlserver

Section titled “Separate keywords, names and numbers with ASCII spaces under sqlserver”

Under sqlserver, the tenant check now also refuses code SQL Server may split into tokens differently (#623). A direct validateTenantGuardrails() call with the built-in sqlserver dialect now reports NOT_SELECT_OR_WITH for a character outside plain code (such as a non-breaking space), a bare money sign (such as $ or €), a \, a number written against a word, or a ? written against a name, as the read-only check already did. Through ask() nothing changes, since that check runs first. Separate keywords, names and numbers with ASCII spaces, and keep other characters inside string literals.

loadSchema() now warns about a key schema.json doesn’t define, such as a misspelled "sensitve": true, with a new SchemaV2Warning kind: { kind: "unknown_key", id, key, suggestion? } (#690). The key is still ignored, so a misspelled sensitive still marks nothing: fix the key the warning names. If your code switches over a warning’s kind exhaustively, add a case for unknown_key. askdb ask and askdb bundle print it on stderr, and Studio lists it under Settings → Schema Warnings. readSchemaDirectory() now keeps schema.json as written, unknown keys included, so a bundle warns like its directory. Loader warnings describes it.

Released with askdb, @askdb/core and @askdb/http-api 1.0.0-beta.55, @askdb/studio 0.2.0-beta.48, @askdb/enrich 0.2.0-beta.26, @askdb/introspect 0.3.0-beta.29, @askdb/rag 0.2.0-beta.35, @askdb/postgres 0.2.0-beta.31, @askdb/prisma 0.2.0-beta.29, @askdb/mysql and @askdb/sqlite 0.1.0-beta.30, and @askdb/sqlserver 0.1.0-beta.31.

Security: upgrade for GHSA-p2v7-pv8j-c264 and GHSA-j363-683j-2pvw

Section titled “Security: upgrade for GHSA-p2v7-pv8j-c264 and GHSA-j363-683j-2pvw”

Two advisories fix places where the tenant check could pass a tenant-scoped query that returned rows belonging to tenants outside the caller’s scope. Both needed the generated SQL to run without database-level tenant enforcement. GHSA-p2v7-pv8j-c264 also needed a way to influence the model’s reply. GHSA-j363-683j-2pvw needs no unusual SQL, so a model’s own mistake can trigger it.

  • GHSA-p2v7-pv8j-c264: the tenant check could read a name differently from the engine, so a tenant-scoped query could pass without an effective tenant predicate.
  • GHSA-j363-683j-2pvw: the tenant check counted a join without checking which columns it equated, so a tenant-scoped query could pass without an effective tenant predicate (#400). The fix is partial: read the Postgres way, a column list after a joined table’s alias (JOIN clients c (sub_agency_id, x)) renames that table’s columns, and the check still reads them by the policy’s names, so a join path can pass on the wrong column. Known limits lists it.

What to do:

  • Upgrade @askdb/core to 1.0.0-beta.55, and every AskDB package you install that depends on it: askdb and @askdb/http-api 1.0.0-beta.55, @askdb/studio 0.2.0-beta.48, @askdb/introspect 0.3.0-beta.29, @askdb/enrich 0.2.0-beta.26, @askdb/rag 0.2.0-beta.35, @askdb/postgres 0.2.0-beta.31, @askdb/prisma 0.2.0-beta.29, @askdb/mysql and @askdb/sqlite 0.1.0-beta.30, and @askdb/sqlserver 0.1.0-beta.31. These pin @askdb/core exactly (@askdb/prisma through @askdb/introspect), so check with npm ls @askdb/core or pnpm why @askdb/core that no older copy remains under them. @askdb/client runs your app’s own @askdb/core.
  • Expect some correct queries to be refused. Join a path table on the columns the policy declares and Quote a table or column name CockroachDB lower-cases into another list them, with the change each needs.
  • Check your SQL Server URLs too. Apart from the advisories, this release changes how encrypt and trustServerCertificate read: Check the TLS settings in your SQL Server URLs.
  • Keep a fallback to sql. Also apart from the advisories, ask() drops its reuse artifacts in more cases: Fall back to sql when the reuse artifacts are absent.
  • If you can’t upgrade at once, enforce tenant isolation in the database (for example row-level security), as Run generated SQL safely recommends. It limits the rows a statement can read, whatever its text.

Join a path table on the columns the policy declares

Section titled “Join a path table on the columns the policy declares”

Under strict enforcement, some joins that passed now throw TenantGuardrailError (MISSING_TENANT_PREDICATE) (#400). A table scoped through a join path passed when each join column’s name appeared somewhere and the path’s root was filtered, so a join on the wrong ID column passed; so did a root filtered through another table’s column with no join between them. Now each hop must equate the declared columns (appointments.client_id = clients.id) on the reads joined, in the same SELECT, up to a filtered read, and a table along the path counts only through the path’s next step. A path whose steps stop short of a root’s table (only shipments.order_id → orders.id, with orders filtered on its own column) proves nothing now: extend the path to the root. A scoped table joined to a filtered root’s ID, which was refused, now passes. A join through a CTE or derived table, or to a source that only borrows a policy table’s name as its alias (JOIN lookup_states clients), isn’t followed, and col IN (SELECT …) no longer carries a predicate out of its subquery, so id IN (SELECT id FROM orders WHERE agency_id = …) is refused: filter the read where it is. In a SELECT with an outer join, no ON counts, for a tenant predicate or for a join: the outer join keeps rows an ON never matched. Put the predicate in WHERE, and write a join path as JOIN when WHERE filters its joined side (appointments a LEFT JOIN clients c ON a.client_id = c.id WHERE c.id IN (…) is refused; with JOIN it passes). The prompt now tells the model to put tenant predicates and join-path equalities in WHERE in a query with an outer join or OUTER APPLY, and to filter a scoped table on the side an outer join may leave unmatched in a derived table or CTE, where a filter in WHERE would drop the unmatched rows. A qualified column named left, right, full or outer (o.full) marks its SELECT the same way: quote it (o."full").

Under sqlite, an ON that relies on a comma-joined read, or on a read joined after it, is refused too: SQLite lets an ON see those reads, and the check doesn’t. Write the join out (JOIN … ON in order), or move the predicate to WHERE.

In a join the check relies on, each table counts only where every engine the dialect covers reads it as the schema’s table. Quoted, it has to be spelled as the schema spells it. Unquoted, any ASCII case works on Postgres, CockroachDB and SQLite, but on MySQL, MariaDB and SQL Server it has to be spelled as the schema spells it, since their servers can keep a table name’s case. So JOIN "CLIENTS" c, which Postgres reads as another table than clients, no longer counts, and neither does an unquoted name with an upper-case non-ASCII letter under postgres or cockroachdb. Write each table as the schema spells it.

The check is still a heuristic, and some shapes still pass, such as an unqualified predicate in an ON that counts for a read the join doesn’t see where a name is spelled like a join word (NATURAL or USING as a SQL Server alias). Known limits lists them. Keep row-level security in the database as the tenant boundary.

Quote a table or column name CockroachDB lower-cases into another

Section titled “Quote a table or column name CockroachDB lower-cases into another”

An unquoted name whose letters, lower-cased, aren’t in Unicode normalization form C is now refused (UNICODE_ESCAPED_IDENTIFIER) unless the schema spells it exactly: CockroachDB lower-cases such a name and then normalizes it, so J followed by U+030C reads as ǰ, and ΠΡΩΤΕΪ́ΝΗ as πρωτεΐνη. It affects about thirty letters, ΐ and ΰ among them in modern Greek. Write the name as the schema spells it. The schema’s spelling now excuses such a name, or one with İ, ı, ſ or the Kelvin sign, only when it is quoted, or lower-case and in NFC: CockroachDB reads an unquoted İTEMS as items, whatever the schema spells. Quote it as the schema spells it ("İTEMS"). A table name ending in a capital sigma (ΟΣ) is now matched to οσ before a column list and in a derived table’s items, as CockroachDB reads it.

Fall back to sql when the reuse artifacts are absent

Section titled “Fall back to sql when the reuse artifacts are absent”

With parameterize on, ask() now drops unboundSql, params, parameters and preparedQuery whenever they differ from the checked sql in a literal, a quoted name, or how tokens join (#359). The consistency check compared the two forms lower-cased with whitespace collapsed, so a manifest value of "colorado" passed against a bound 'Colorado', and unboundSql with params could run something other than the checked sql on a case-sensitive comparison. It now compares tokens under the dialect’s lexer: strings, quoted names, numbers, parameters and comments must match exactly, a keyword or unquoted name compares as the engine folds it, and tokens that touch on one side must touch on the other. sql itself is unchanged; only the reuse artifacts are dropped. If your code runs unboundSql with params, fall back to sql when they’re absent, as Executing the result shows.

Check the TLS settings in your SQL Server URLs

Section titled “Check the TLS settings in your SQL Server URLs”

AskDB now reads encrypt and trustServerCertificate the same way in mssql:// and sqlserver:// URLs (#596). true, yes and 1 mean true, and false, no and 0 mean false, in any case. Any other encrypt value turns encryption on, and any other trustServerCertificate value leaves the server certificate untrusted.

  • sqlserver:// with encrypt=yes or encrypt=1 now requires TLS. Before, only encrypt=true turned encryption on, so these strings connected without it. A server that doesn’t offer TLS now refuses the connection. Write encrypt=false if you meant to connect without encryption.
  • sqlserver:// with trustServerCertificate=yes or =1 now trusts the server certificate, as =true does. Before, the certificate was checked.
  • An unknown value no longer trusts the certificate. In an mssql:// URL, a trustServerCertificate value other than false, no or 0 used to trust it. Now only true, yes and 1 do. In a sqlserver:// URL, an unknown encrypt value used to leave encryption off. Now it turns encryption on.

An ADO.NET string whose value holds ://, such as a password, now has its spaced keys read too (#595). Before, Trust Server Certificate=True was dropped from such a string, so the certificate was checked.

Released with askdb, @askdb/core and @askdb/http-api 1.0.0-beta.54, @askdb/studio 0.2.0-beta.47, @askdb/client 1.0.0-beta.14, @askdb/enrich 0.2.0-beta.25, @askdb/introspect 0.3.0-beta.28, @askdb/rag 0.2.0-beta.34, @askdb/postgres 0.2.0-beta.30, @askdb/prisma 0.2.0-beta.28, @askdb/mysql and @askdb/sqlite 0.1.0-beta.29, and @askdb/sqlserver 0.1.0-beta.30.

GHSA-rx3m-m5jr-pwch: the tenant check could read a name’s letters differently from the engine, so a tenant-scoped query could pass without an effective tenant predicate. The engine could then return rows belonging to tenants outside the caller’s scope. Getting there needed a way to influence the model’s reply. The generated SQL also had to run without database-level tenant enforcement.

What to do:

  • Upgrade @askdb/core to 1.0.0-beta.54, and every AskDB package you install that depends on it: askdb and @askdb/http-api 1.0.0-beta.54, @askdb/studio 0.2.0-beta.47, @askdb/introspect 0.3.0-beta.28, @askdb/enrich 0.2.0-beta.25, @askdb/rag 0.2.0-beta.34, @askdb/postgres 0.2.0-beta.30, @askdb/prisma 0.2.0-beta.28, @askdb/mysql and @askdb/sqlite 0.1.0-beta.29, and @askdb/sqlserver 0.1.0-beta.30. These pin @askdb/core exactly (@askdb/prisma through @askdb/introspect), so check with npm ls @askdb/core or pnpm why @askdb/core that no older copy remains under them. @askdb/client runs your app’s own @askdb/core.
  • Expect some correct queries to be refused. Write a tenant column with non-ASCII letters as the schema spells it lists them, with the change each needs.
  • If you can’t upgrade at once, enforce tenant isolation in the database (for example row-level security), as Run generated SQL safely recommends. It limits the rows a statement can read, whatever its text.

Write a tenant column with non-ASCII letters as the schema spells it

Section titled “Write a tenant column with non-ASCII letters as the schema spells it”

A tenant column, a polymorphic discriminator or a HAVING predicate’s GROUP BY key whose name has non-ASCII letters now counts only when every engine read the same way reads it as the schema’s column; otherwise the predicate is refused (MISSING_TENANT_PREDICATE or MISSING_TYPE_DISCRIMINATOR):

  • Postgres and CockroachDB: quote a column with an upper-case non-ASCII letter (c."Égency_id"). Unquoted, Postgres keeps the letter and CockroachDB lower-cases it, so they read different columns.
  • MySQL, MariaDB and SQLite: write a non-ASCII letter in the schema’s case. Only ASCII letters match in any case.

A schema whose column names are ASCII is unaffected by that rule. Three more changes reach any schema (UNICODE_ESCAPED_IDENTIFIER or MISSING_TENANT_PREDICATE):

  • Aliases and qualifiers on Postgres and CockroachDB: an unquoted alias or qualifier with an upper-case non-ASCII letter (Ö.agency_id) ties a predicate to no read. Quote it, as the alias is ("Ö".agency_id), or write it in lower case.
  • İ, ı, ſ, the Kelvin sign and names not in NFC: a name holding one of these letters, or not in Unicode normalization form C as written, is refused on every engine unless the schema spells it exactly. A model’s own alias with ı (common in Turkish, as in sayı) is refused too; the schema’s own Turkish names, written as the schema spells them, pass.
  • Tables whose name starts or ends with a non-ASCII letter: the check now sees ürün or café as the scoped table it is, so SQL that read one without a tenant predicate, and passed, is refused.

Released with askdb, @askdb/core and @askdb/http-api 1.0.0-beta.53, @askdb/studio 0.2.0-beta.46, @askdb/enrich 0.2.0-beta.24, @askdb/introspect 0.3.0-beta.27, @askdb/rag 0.2.0-beta.33, @askdb/postgres 0.2.0-beta.29, @askdb/prisma 0.2.0-beta.27, @askdb/mysql and @askdb/sqlite 0.1.0-beta.28, and @askdb/sqlserver 0.1.0-beta.29.

Security: upgrade for GHSA-9w7p-c3w8-4839 and GHSA-mmrm-rfxf-ccc8

Section titled “Security: upgrade for GHSA-9w7p-c3w8-4839 and GHSA-mmrm-rfxf-ccc8”

Two advisories fix places where the tenant check could pass a tenant-scoped query that returned rows belonging to tenants outside the caller’s scope. Both needed the generated SQL to run without database-level tenant enforcement. GHSA-9w7p-c3w8-4839 also needed a way to influence the model’s reply. GHSA-mmrm-rfxf-ccc8 needs no unusual SQL, so a model’s own mistake can trigger it.

  • GHSA-9w7p-c3w8-4839: the tenant check could read some table names, column names, HAVING clauses and conditions differently from the engine, so a tenant-scoped query could pass without an effective tenant predicate.
  • GHSA-mmrm-rfxf-ccc8: the tenant check tied a predicate to reads it doesn’t filter, so a tenant-scoped query could pass without an effective tenant predicate.

What to do:

  • Upgrade @askdb/core to 1.0.0-beta.53, and every AskDB package you install that depends on it: askdb and @askdb/http-api 1.0.0-beta.53, @askdb/studio 0.2.0-beta.46, @askdb/introspect 0.3.0-beta.27, @askdb/enrich 0.2.0-beta.24, @askdb/rag 0.2.0-beta.33, @askdb/postgres 0.2.0-beta.29, @askdb/prisma 0.2.0-beta.27, @askdb/mysql and @askdb/sqlite 0.1.0-beta.28, and @askdb/sqlserver 0.1.0-beta.29. These pin @askdb/core exactly (@askdb/prisma through @askdb/introspect), so check with npm ls @askdb/core or pnpm why @askdb/core that no older copy remains under them. @askdb/client runs your app’s own @askdb/core.
  • Expect some correct queries to be refused. Give each read of a scoped table its own tenant predicate and Put a tenant predicate in WHERE when a query groups list them, with the change each needs.
  • If you can’t upgrade at once, enforce tenant isolation in the database (for example row-level security), as Run generated SQL safely recommends. It limits the rows a statement can read, whatever its text.

Give each read of a scoped table its own tenant predicate

Section titled “Give each read of a scoped table its own tenant predicate”

Under strict enforcement, some SQL that passed before now throws TenantGuardrailError (MISSING_TENANT_PREDICATE) (#399). The tenant check looked for a predicate per statement or UNION branch. So a predicate inside a CTE, derived table or subquery could cover an unfiltered read of the same table outside it, and a predicate on one reference could cover another. Now a CTE body, a derived table and a subquery are each their own SELECT, and a qualified predicate counts only for the read it names. A read inside a derived table or CTE still passes when every reference to it is filtered on the tenant column. AskDB now refuses some correct queries:

  • a root table joined for a label from a CTE, derived table or scalar subquery whose own SELECT doesn’t filter it;
  • a read inside a correlated subquery that only the outer query filters;
  • a source that computes over its rows (an aggregate, a window, LIMIT), filtered only outside it;
  • a source whose body is a set operation (UNION, INTERSECT, EXCEPT), filtered only outside it;
  • a read with a clause right after its table or alias (orders TABLESAMPLE …, SQL Server’s orders o (NOLOCK) without WITH), filtered only through that name;
  • a self-join where a read has no alias, a quoted alias or sits in parentheses, or that is filtered unqualified (FROM orders a, orders b WHERE agency_id = …): give each read a plain alias and its own predicate (WHERE a.agency_id = … AND b.agency_id = …);
  • on MySQL, MariaDB and SQL Server, a predicate whose qualifier spells the alias in another case (FROM orders O WHERE o.agency_id = …).

For each, add the predicate inside that SELECT (in each branch of a set operation), filter through an alias or unqualified, and write an alias the same way each time.

The check is still a heuristic, and some shapes still pass: a root filtered through another table’s tenant column isn’t checked for the join between them (#400), and an unqualified predicate in an ON can count for a read the join doesn’t see where a name is spelled like a join word (NATURAL or USING as a SQL Server alias). Known limits lists them. Keep row-level security in the database as the tenant boundary.

Check what your business context and column notes tell the model

Section titled “Check what your business context and column notes tell the model”

The prompt ask() sends now carries each table’s ## Business context section, under the table after its common query language, and each column’s ## Column notes item, on that column’s line (#894, #898). With a retriever, it carries the business context retrieval picks, and every listed column’s note. Before, they reached neither prompt: a retrieved business-context or column chunk only picked its table, though the schema contract said its text would reach the model. A retrieved concept also keeps its label now, and a tenant-policy section its name.

Your language model provider now receives this text, not only your embedding provider. Read those sections in your tables/*.md files for anything the model shouldn’t see. They follow the rules for common query language: a sensitive table’s business context and a sensitive column’s note never reach the prompt, and with modes.omitSensitiveFromPrompt (--omit-sensitive-from-prompt) any of it that names a sensitive column is withheld, a note together with the rest of its column’s annotation. loadSchema() now returns them as a table’s businessContext and a column’s note. Privacy model lists what the model sees. The index doesn’t need rebuilding. If your tests compare formatSchemaV2ForNlToSql()’s or synthesizeRetrievedDdl()’s output, or loadSchema()’s result, expect the new lines and fields.

Check the foreign keys and concepts the prompt now sends

Section titled “Check the foreign keys and concepts the prompt now sends”

The prompt ask() sends now lists each table’s foreign keys and, without a retriever, your concepts (#898). Before, foreign keys reached the model only through retrieval with emitRelationships: true, and concepts only through retrieval. A foreign key prints after its table’s columns, as FK (user_id) -> public.users (id), and the concepts follow the tables, each with its label, synonyms, links and description. With a retriever, the prompt lists foreign keys only between the tables it shows, and still only the concepts retrieval picks, now in the same shape. A retrieved relationship chunk (emitRelationships: true) no longer adds a -- relationships -- line: its foreign key prints under its table.

Your language model provider now receives your foreign keys and concepts without a retriever. Read concepts.md for anything the model shouldn’t see. A foreign key or concept never names an untracked table, and with modes.omitSensitiveFromPrompt (--omit-sensitive-from-prompt) a foreign key with a sensitive column or table on either side, and a concept that names or links one, are left out. The prompt grows by about 18 tokens per foreign key and 37 per concept, so past about 22 tables, consider retrieval. If your tests compare formatSchemaV2ForNlToSql()’s or synthesizeRetrievedDdl()’s output, expect the new lines.

Put a tenant predicate in WHERE when a query groups

Section titled “Put a tenant predicate in WHERE when a query groups”

A tenant predicate in HAVING now counts only when its column is one the SELECT groups by: HAVING filters groups, and on a column that isn’t grouped, SQLite and MariaDB take one row’s value for the whole group. Read the MySQL way (mysql, mariadb, a dialect id that is no built-in engine, or none), an unqualified column that a select-list item is aliased to doesn’t count either, since MySQL and MariaDB read it as that item. Under strict, such SQL throws TenantGuardrailError (MISSING_TENANT_PREDICATE). Move the predicate to WHERE, or group by the tenant column.

Two more shapes that passed are refused: a tenant predicate written as a BETWEEN’s upper bound (0 BETWEEN 0 AND agency_id = …; write it as its own conjunct), and, under sqlite, a custom id or none, a read written FROM ('orders') with no predicate, which SQLite reads as the table.

The tenant column and a polymorphic discriminator now count only when written as the schema spells them; otherwise the predicate is refused (MISSING_TENANT_PREDICATE or MISSING_TYPE_DISCRIMINATOR):

  • Postgres and CockroachDB: on a schema with upper-case column names (camelCase, as Prisma writes them), write the column quoted, as c."agencyId". An unquoted agencyid or agencyId folds to lower case and names another column. A schema with lower-case column names is unaffected.
  • SQL Server: write the column as the schema spells it (AgencyId, not agencyid). A database’s collation may compare names case-sensitively, and the check can’t see it, so a column in another case is refused even on a case-insensitive database.
  • SQLite: a non-ASCII letter must be in the schema’s case, since SQLite folds only ASCII letters.
  • A custom dialect or none: all of the above apply, so a column in another case than the schema is refused whatever the engine. Set dialect to avoid that.

Released with askdb, @askdb/core and @askdb/http-api 1.0.0-beta.52, @askdb/studio 0.2.0-beta.45, @askdb/config 1.0.0-beta.20, @askdb/client 1.0.0-beta.13, @askdb/ai 0.1.0-beta.12, @askdb/enrich 0.2.0-beta.23, @askdb/introspect 0.3.0-beta.26, @askdb/rag 0.2.0-beta.32, @askdb/postgres 0.2.0-beta.28, @askdb/prisma 0.2.0-beta.26, @askdb/mysql and @askdb/sqlite 0.1.0-beta.27, and @askdb/sqlserver 0.1.0-beta.28.

Security: upgrade for GHSA-rhwf-7742-8hwj, GHSA-rpcp-322q-q738 and GHSA-cjwq-phcm-mwg7

Section titled “Security: upgrade for GHSA-rhwf-7742-8hwj, GHSA-rpcp-322q-q738 and GHSA-cjwq-phcm-mwg7”

Three advisories fix places where the tenant check read SQL differently from the database engine. In each, a tenant-scoped query could pass the check while the engine read it without an effective tenant predicate, and returned rows belonging to tenants outside the caller’s scope. Getting there needed a way to influence the model’s reply, and SQL run without database-level tenant enforcement.

  • GHSA-rhwf-7742-8hwj: the tenant check read a number or $N parameter written right against a keyword (1.0OR, $1OR) as one word, so a tenant-scoped query could pass with its predicate beside an OR the engine reads.
  • GHSA-rpcp-322q-q738: the tenant check could match a name, a placeholder, a column or a join differently from the database engine, so a tenant-scoped query could pass without an effective tenant predicate.
  • GHSA-cjwq-phcm-mwg7: the tenant check ended a SQL Server name or number where SQL Server doesn’t, so a tenant-scoped query could pass with its predicate beside an OR the engine reads (#623).

What to do:

  • Upgrade @askdb/core to 1.0.0-beta.52, and every AskDB package you install that depends on it: askdb and @askdb/http-api 1.0.0-beta.52, @askdb/studio 0.2.0-beta.45, @askdb/introspect 0.3.0-beta.26, @askdb/enrich 0.2.0-beta.23, @askdb/rag 0.2.0-beta.32, @askdb/postgres 0.2.0-beta.28, @askdb/prisma 0.2.0-beta.26, @askdb/mysql and @askdb/sqlite 0.1.0-beta.27, and @askdb/sqlserver 0.1.0-beta.28. These pin @askdb/core exactly (@askdb/prisma through @askdb/introspect), so check with npm ls @askdb/core or pnpm why @askdb/core that no older copy remains under them. @askdb/client runs your app’s own @askdb/core.
  • On SQL Server, expect a non-ASCII name to be refused. Under sqlserver, a dialect id that is no built-in engine, or none, the tenant check refuses a name that holds a character outside printable ASCII (UNICODE_ESCAPED_IDENTIFIER), and the sensitive check reports it as unresolved scope, unless the schema spells it exactly. So a non-ASCII alias or CTE name such as [Città] is refused unless the schema has that name.
  • Expect these refusals too, from GHSA-rpcp-322q-q738’s fixes, in its words. Wherever the SQL may be read as Postgres, a read whose alias has a column list, or that sits inside a parenthesized FROM item whose alias has one, counts as unfiltered (MISSING_TENANT_PREDICATE), and a quoted tenant column counts only when spelled as the schema spells it. A join is outer when LEFT, RIGHT, FULL or OUTER appears among its words, whatever hint stands beside it, so CockroachDB’s LEFT STRAIGHT JOIN, LEFT LOOKUP JOIN and LEFT INVERTED JOIN are outer joins, and an ON that closes an earlier join is read as that join’s. Under sqlite, a dialect id that is no built-in engine, or none, a '…' right after FROM, JOIN or IN, after a comma in a FROM list, or beside a . is read as that table’s name by the tenant and sensitive checks.
  • If you can’t upgrade at once, enforce tenant isolation in the database (for example row-level security), as Run generated SQL safely recommends. Each advisory lists the narrower workarounds for its cases.

@askdb/core (#852) and @askdb/config (#830) no longer export the internal helpers below. Their API pages listed them as internal, with no stability promise, and no AskDB package outside their own imported them. They still run inside the package, so ask(), loadSchema() and bootstrapAskDbEnv() behave as before. If your code imports one, use the replacement in its row.

RemovedUse instead
flattenAskDbConfig(config) (@askdb/config)defineConfig(config).entries, the same map.
isAskDbEnvProjection(value), ASKDB_ENV_PROJECTION (@askdb/config)None. loadAskDbConfigProjection() loads a config file written either way and returns its projection.
v2TableSchema, v2ColumnSchema, v2TableFrontmatterSchema, v2ColumnFrontmatterSchema, v2ConceptsFrontmatterSchema, v2ConceptSchema, tenantRootSchema, hierarchyEdgeSchema, scopedTableSchema, polymorphicTableSchema, enforcementModeSchema, tenantAccessSchemaloadSchema() or loadSchemaFromJson() to check a whole schema artifact. parseTableMarkdown(), parseConceptsMarkdown() or parseTenantPolicyMarkdown() to check one file. validateTenantScope() to check a TenantScope. The types, such as V2Table and TenantRoot, stay exported.
RECOGNIZED_H2_SECTIONS, TENANT_POLICY_H2_SECTIONSThe types RecognizedH2Section and TenantPolicyH2Section, which name the keys of a parsed file’s sections.
assertNlToSqlInputs(), buildNlToSqlSystemPrompt()None. generateSelectSql() checks the inputs and sends the system prompt. It’s the generation step ask() runs for a built-in dialect or a DialectSpec.
buildSelectGuardrailExplanation()ask({ explain: true }), which returns the summary as result.explain for a built-in dialect or a DialectSpec.
escapeSqlLiteral()None. Pass values to your driver as bound parameters.
extractTenantPlaceholders()resolveTenantSql(), whose bindings name each placeholder it replaced.
parseAskDbSchemaJson(), normalizeAskDbSchema()loadNormalizedSchemaFromJson(), which runs both steps on a v1 schema.

Replace the unused exports removed before 1.0

Section titled “Replace the unused exports removed before 1.0”

@askdb/ai, @askdb/config, @askdb/core and @askdb/introspect no longer export the values below (#654). Nothing outside their own package used them, so they leave before 1.0 freezes the API (ADR 0031). Each still works inside its package, except bootstrapAskDbRuntime and the deprecated placeholderForRoot, which are removed.

RemovedUse instead
aiKeyMissingMessage(context) (@askdb/ai)registry.keyMissingMessage(context), which names the registry’s own providers.
aiProviderMissingMessage(provider) (@askdb/ai)None. A registry throws this message when it has no adapter for the provider; registry.hasProvider(provider) checks first.
isReasoningEffort(value) (@askdb/ai)REASONING_EFFORTS from @askdb/ai, the four values to check a string against.
bootstrapAskDbRuntime(options) (@askdb/config)bootstrapAskDbEnv(options), the same function. The not-initialized error now names only bootstrapAskDbEnv().
ASKDB_CONFIG_EXTENSION_PRECEDENCE (@askdb/config)discoverAskDbConfigPath(cwd), which returns the file this order picks. The config API reference lists the order.
DEFAULT_HTTP_API_REQUEST_TIMEOUT_MS (@askdb/config)getAskDbRuntimeConfig().httpApi.requestTimeoutMs, the resolved value; the default is 60000.
DEFAULT_STUDIO_EXECUTE_TIMEOUT_MS, DEFAULT_STUDIO_EXECUTE_MAX_ROWS (@askdb/config)getAskDbRuntimeConfig().studio.execute.timeoutMs and .maxRows; the defaults are 30000 and 500.
DEFAULT_PGVECTOR_INDEX_STRATEGY (@askdb/config)"hnsw", the default for rag.storeConfig.pgvector.indexStrategy.
DEFAULT_MOCK_RAG_EMBEDDING_DIMENSIONS (@askdb/config)64, the mock embedder’s width unless askdb rag --dimensions asks for another.
renderAskDbAiConfigScaffold(input) and the types AskDbAiConfigScaffold, AskDbScaffoldEnvVar (@askdb/config/scaffold)planAskDbScaffold(answers, versions): its configSource holds the ai block, and envVars lists the variables the config reads.
placeholderForRoot(label) (@askdb/core)placeholderForTenantRoot(root), which also handles a label with no ASCII letter or digit.
ENRICHMENT_SYSTEM_PROMPT, buildEnrichmentUserPrompt(), parseCandidates() (@askdb/core)suggestEnrichment(target, context, model, deps?), which sends the prompt and splits the reply. deps.generateText replaces the model call when you need to make it yourself.
connectorProviderMissingMessage(provider) (@askdb/introspect)None. A connector registry throws this message when it has no adapter for the provider; registry.hasProvider(provider) checks first.

A JavaScript config loads with Node’s own loader

Section titled “A JavaScript config loads with Node’s own loader”

@askdb/config loads askdb.config.js, .cjs and .mjs with Node’s own loader, and compiles only a TypeScript config (.ts, .mts, .cts) with jiti (#833). A JavaScript config now loads from inside an app’s bundle, webpack included; see Bundle an app that uses AskDB.

  • A .js config that relied on jiti may now fail, with Node’s own error. jiti accepted ES module syntax in a file Node reads as CommonJS, such as export default in askdb.config.js beside a package.json with "type": "commonjs". Node now throws SyntaxError: Unexpected token 'export'. Rename the file to askdb.config.mjs, or write module.exports = { … }. A JavaScript config that imports TypeScript written for a compiler, such as a .ts file whose imports name ./other.js for ./other.ts, fails too: rename the config to askdb.config.ts.
  • Node’s rules decide the module format, as for any file Node loads: .cjs is CommonJS, .mjs is an ES module, and .js follows the nearest package.json. A TypeScript config loads as before.
  • An ES module config that throws while it loads loads again only after a restart. Node keeps the failed module, so a process that loads the config again, as Studio does after you edit .env, gets an error that says to restart it. A CommonJS or TypeScript config loads again once fixed.

Fix an unknown studio.execute.provider, which now fails to load

Section titled “Fix an unknown studio.execute.provider, which now fails to load”

A studio.execute.provider that isn’t postgres, mysql, sqlite or sqlserver now fails to load with askdb.config: invalid studio.execute.provider "postgress" (expected one of: postgres, mysql, sqlite, sqlserver). (#751). Before, Studio quietly ran Execute against the introspection provider, or postgres, so a typo could point it at an engine you didn’t name. Fix the value, or remove the key to keep that fallback: an unset provider still uses the introspection provider when it’s a live engine, else postgres.

Set dialect for a schema whose engine AskDB doesn’t ship, in Studio

Section titled “Set dialect for a schema whose engine AskDB doesn’t ship, in Studio”

Studio’s Playground now refuses a schema whose provider has no AskDB dialect, as the CLI and the HTTP API already did (#886, ADR 0039). It answers 422 with @askdb/client’s DialectNotSupportedError message and generates nothing. Before, it generated PostgreSQL SQL and checked it under PostgreSQL’s rules, which can pass SQL your engine reads differently. The workspace still loads for browsing and enrichment, and the Get the code panel shows the reason instead of code. If your engine speaks a dialect AskDB ships, set dialect in askdb.config.ts, such as "postgres" for a PostgreSQL-compatible warehouse.

  • A tenant policy with a table named order or group no longer refuses every sorted query (#828). The tenant check read the ORDER of ORDER BY (and GROUP of GROUP BY) as a reference to a table of that name, so under strict enforcement a sorted, tenant-scoped query was refused with UNKNOWN_TABLE_REFERENCED or MISSING_TENANT_PREDICATE. It no longer reads those keywords as a table; a quoted or qualified "order", or order in FROM/JOIN, still counts as the table. If you worked around this by renaming the table or dropping ORDER BY, you can undo that.
  • On SQL Server, the tenant check ends a name or number where SQL Server does (#623). SQL Server ends a number after 0x or 1E, and a name at a non-breaking space (U+00A0), U+200B or U+0085, so a keyword written right after one is a keyword: it reads 1=0xOR 1=1 as 1=0x OR 1=1. The tenant check now reads those ends the same way under sqlserver, and under a dialect id that is no built-in engine or no dialect, which may be SQL Server. An OR written that way is required to keep the tenant predicate ANDed in, as one after a space always was. ask() already refused these shapes through the read-only check, so the change shows in a direct call to validateTenantGuardrails().
  • A $N parameter written against a word is refused where the SQL may be read as Postgres (#665). The read-only check read $1INTO and $1FOR as one parameter, so it never saw the INTO or FOR. Postgres 15 and later reject that SQL, but a server that ends the parameter at its last digit would run the keyword. It now refuses the shape with SQL_FORBIDDEN_KEYWORD under postgres, cockroachdb and a dialect id that is no built-in engine. If a stored template writes a parameter that way, put a space after it.
  • Internal: every SQL check reads through the shared lexer (#815, ADR 0010). One module now sets the readings each check takes for each dialect, and the tenant check’s separate scanner is gone, so a lexer fix lands in one place. #815 itself changes no public API and no verdict beyond #828.
  • askdb init installs in a one-package project whose pnpm-workspace.yaml holds only settings (#863), such as minimumReleaseAgeExclude, which pnpm also keeps in that file. It used to skip the install there and print the pnpm add command instead. It still skips at a workspace root: a pnpm-workspace.yaml that lists at least one package under packages, or a package.json with workspaces. Studio’s guided setup reads the file the same way.
  • askdb init and Studio’s setup read a pgvector store’s URL from PGVECTOR_URL, not ASKDB_PGVECTOR_URL, when you don’t name a variable. That’s the name the rest of these docs use. --pgvector-env, or the variable field in Studio’s setup, still picks another. A config that already reads env("ASKDB_PGVECTOR_URL") keeps working: the name is whatever its env() call passes.
  • @askdb/client exports resolveDialect(schema, options?), the rule createAskDb() runs to pick a dialect (#886). A host that calls @askdb/core’s ask() itself can pick the dialect the CLI, Studio and the HTTP API pick, and refuse the same schemas.

Released with askdb, @askdb/core and @askdb/http-api 1.0.0-beta.51, @askdb/studio 0.2.0-beta.44, @askdb/enrich 0.2.0-beta.22, @askdb/introspect 0.3.0-beta.25, @askdb/rag 0.2.0-beta.31, @askdb/postgres 0.2.0-beta.27, @askdb/prisma 0.2.0-beta.25, @askdb/mysql and @askdb/sqlite 0.1.0-beta.26, and @askdb/sqlserver 0.1.0-beta.27.

A residual of GHSA-8pwj-6hfv-5pm4’s first case. A Postgres or CockroachDB dollar-quoted string may carry non-ASCII letters in its tag ($é$ … $é$), and the engine and AskDB’s shared lexer read it as a string. The tenant check’s scanner read the tag as ASCII-only, so it read such a string as code, and a statement could pass the check while the engine read it with no effective tenant predicate, returning rows belonging to tenants outside the caller’s scope (GHSA-rmjw-pffc-5hjr). This reached the built-in postgres and cockroachdb dialects, a DialectSpec under one, a custom AskDialect, and direct calls to the check. The scanner now reads the tag with the same characters as the shared lexer and the engine.

  • Upgrade @askdb/core, askdb and @askdb/http-api to 1.0.0-beta.51, and @askdb/studio to 0.2.0-beta.44. These pin @askdb/core exactly, so check with npm ls @askdb/core or pnpm why @askdb/core that no older copy remains under them. @askdb/client runs your app’s own @askdb/core.
  • One case now passes: under postgres and cockroachdb, a table named only inside such a string no longer counts as read, as the engine reads it.
  • No setting closes it on 1.0.0-beta.50 or earlier. Enforce tenant isolation in the database (for example Postgres row-level security), as Run generated SQL safely recommends.

Replace createOpenAiEmbedder from @askdb/rag

Section titled “Replace createOpenAiEmbedder from @askdb/rag”

@askdb/rag no longer exports createOpenAiEmbedder or CreateOpenAiEmbedderOptions, and @askdb/rag/embedders/openai is no longer an entry point (#844). Importing either now fails. Pick the replacement by where your embedding settings live:

  • askdb rag or Studio: set rag.embedder: "ai" and an ai.embedding section, with provider: "openai" and a model, in askdb.config.*. You don’t build an embedder yourself.

  • Your own code, with an askdb.config.* file: build the model from the config’s ai.embedding section with @askdb/ai’s createEmbeddingModelFromSection, and wrap it with createAiSdkEmbedder:

    import { createAiRegistry } from "@askdb/ai";
    import { getAskDbRuntimeConfig } from "@askdb/config";
    import { createAiSdkEmbedder } from "@askdb/rag";
    const embedding = getAskDbRuntimeConfig().ai.embedding; // set when rag.embedder is "ai"
    const model = embedding ? await createAiRegistry().createEmbeddingModelFromSection(embedding) : undefined;
    if (!model) throw new Error('Set rag.embedder: "ai" and ai.embedding, with an API key on its connection.');
    const embedder = createAiSdkEmbedder({ model });
  • Your own code, without a config file: build the AI SDK model yourself, such as createOpenAI({ apiKey, baseURL }).embedding("text-embedding-3-small") from @ai-sdk/openai, and pass it to createAiSdkEmbedder({ model }). The helper’s dimensions and user options move to createAiSdkEmbedder’s providerOptions: { openai: { dimensions, user } }.

The helper picked text-embedding-3-small when you set no model. Name that model and keep your buildSchemaIndex() embedderId as it was, left unset if you never set one, and an index you built with the helper stays valid.

Point each tenant-policy column at its entry’s own table

Section titled “Point each tenant-policy column at its entry’s own table”

The tenant-policy loader now checks that each column the contract puts on an entry’s own table is on that table (#836): a root’s tenantIdColumn and parent.foreignKey, a hierarchy edge’s foreignKey (on the child), a scopeThrough column, and a polymorphic typeColumn and idColumn. Before, it only checked that schema.json had the column somewhere, so a scopeThrough column on another table loaded without a warning and broke the scoped table’s filter path.

  • Under enforcement: strict, such a policy fails to load with SchemaParseError, naming the file, the column and the table it’s on, in loadSchema(), a bundle, and @askdb/enrich’s saveTenantPolicy() (Studio’s Save). Under warn, it loads with the warning. Point the field at the column on the entry’s own table.
  • A switch over every TenantPolicyWarning (in schema.tenantPolicy.warnings) needs a case for the new misplaced_column_id, with id, tableId (the table the column is on) and context. The schema loader’s warning of the same name, in schema.warnings, is unchanged.
  • If you call normalizeTenantPolicy() yourself, its third argument is now a Map from column ID to table ID, read from schema.json: new Map(tables.flatMap((t) => t.columns.map((c) => [c.id, t.id]))).
  • No schema configured server-side answers 500 generation_not_configured from askdb-http, not 400 bad_request (#845), with the cause in the server log (errName SchemaNotConfiguredError). A request that sends its own schemaJson is unaffected. If your client matched bad_request for this case, match generation_not_configured.

Released with askdb, @askdb/core and @askdb/http-api 1.0.0-beta.50, @askdb/studio 0.2.0-beta.43, @askdb/ai 0.1.0-beta.11, @askdb/config 1.0.0-beta.19, @askdb/client 1.0.0-beta.12, @askdb/enrich 0.2.0-beta.21, @askdb/introspect 0.3.0-beta.24, @askdb/rag 0.2.0-beta.30, @askdb/postgres 0.2.0-beta.26, @askdb/prisma 0.2.0-beta.24, @askdb/mysql and @askdb/sqlite 0.1.0-beta.25, and @askdb/sqlserver 0.1.0-beta.26.

The tenant check looks for each tenant-scoped table’s predicate in the model’s SQL, and skips string literals and comments to find it. In four cases it read the SQL differently from the database engine, so a statement could pass the check while the engine read a table, or dropped a predicate, that the check never saw, and returned rows belonging to tenants outside the caller’s scope (GHSA-8pwj-6hfv-5pm4). With enforcement: strict, ask() returned the statement. Getting there needed a way to steer the model’s reply, and SQL run without database-level tenant enforcement. The check now reads each case the way the engine does:

  • A $ inside a name. Postgres, CockroachDB, MySQL, MariaDB and SQLite read x$$y as one name, but the check read its $$ as the start of a dollar-quoted string, under every dialect, and read $tag$…$tag$ as a string on engines that have no such strings. This reached the built-in postgres, cockroachdb, mysql, mariadb and sqlite dialects with default settings, and the CLI, the HTTP API and Studio. Beta.50 still read a dollar-quoted string whose tag holds non-ASCII letters ($é$ … $é$) as code: that residual is GHSA-rmjw-pffc-5hjr, fixed in beta.51.
  • A table named after a keyword. A scoped table written unquoted under a name some engine takes as a plain name, such as user on MySQL or lock, qualify and by on Postgres, wasn’t checked for its predicate.
  • An unset backslashEscapes on a MySQL or MariaDB DialectSpec. The check read it as escapes off, while the server and the rest of AskDB read it as on. A spec that spreads MYSQL_DIALECT or MARIADB_DIALECT keeps the field and wasn’t affected.
  • Where a -- comment ends. Postgres and SQL Server end it at a carriage return as well as at a line feed, and the check ended it only at a line feed. This reached a custom AskDialect, which skips the read-only check that refuses comments, and direct calls to the checks. On MySQL, MariaDB and SQLite the sensitive-column check had the opposite difference.

What to do:

  • Upgrade @askdb/core, askdb and @askdb/http-api to 1.0.0-beta.50, and @askdb/studio to 0.2.0-beta.43. These pin @askdb/core exactly, so check with npm ls @askdb/core or pnpm why @askdb/core that no older copy remains under them. @askdb/client runs your app’s own @askdb/core.
  • Expect more refusals in these cases. A scoped table named after a keyword now counts wherever its name appears outside ORDER BY and GROUP BY: with a scoped table named limit, a query that uses LIMIT needs that table’s tenant predicate too. A table named after a -- comment that a carriage return ends is now read as code on Postgres and SQL Server, so a query that reads it without a predicate is refused. Under a custom AskDialect, a DO $$ … $$ block’s tables are now reported beside NOT_SELECT_OR_WITH.
  • One case now passes: under sqlserver, a predicate written after a comment that a carriage return ends counts, as SQL Server reads it.
  • If you can’t upgrade at once, set backslashEscapes: true on a MySQL-family DialectSpec you built yourself (false only for a NO_BACKSLASH_ESCAPES server), or spread MYSQL_DIALECT; and in a custom AskDialect, run validateSelectSql(spec, sql) with your engine’s built-in spec before executing, since it refuses comments. No setting closes the first two cases.
  • Enforce tenant isolation in the database (for example Postgres row-level security), as Run generated SQL safely recommends. It limits the rows a statement can read regardless of its text.

Handle INVALID_DECLARATION from bindPreparedQuery()

Section titled “Handle INVALID_DECLARATION from bindPreparedQuery()”

bindPreparedQuery() now refuses a stored template whose parameter declares a type or cardinality AskDB doesn’t support. It throws QueryParameterError with the new QueryParameterRejectionReason INVALID_DECLARATION, naming the parameter, before it reads any value (#789).

  • An unknown type, anything but string, number, boolean, date or datetime, used to fail as a raw TypeError.
  • An unknown cardinality is a behavior change. A template that declared anything but one or many used to bind as one; it now throws.
  • A switch over every QueryParameterRejectionReason needs a case for the new member.
  • Fix the template, not the value: regenerate it with ask(). A template ask() produced always declares supported values; one edited or built by hand may not.

A model base URL with a username or password, such as http://user:[email protected]/v1, now fails before any request (#787, ADR 0036). Such a URL never worked: fetch refused it with an error that quoted the whole URL, credentials included, in SqlGenerationError and the askdb.pipeline.failed log.

  • A config that sets one fails to load: in any ai.providerConfig.<provider>.baseUrl, even on a connection no section uses. The askdb.config: error names the key.
  • @askdb/ai throws the new AiBaseUrlCredentialsError for one in a config section’s connection.baseUrl, a base URL env variable, an AiConfig’s baseURL, or the OPENAI_BASE_URL or ANTHROPIC_BASE_URL the provider SDK would read from the environment. Its setting names where the URL came from.
  • Neither error repeats the URL. Put the key in apiKey. For other credentials, such as a proxy’s, build the model in code with the AI SDK provider’s headers option (see ai.providerConfig).

The config keys deprecated in beta.44 (@askdb/config 1.0.0-beta.13), and rag.storeConfig.file.autoFlush from beta.47, are removed (#765). A config that still sets one fails to load. The askdb.config: error names each such key and its replacement, and never repeats a value. A key counts as set whatever its value, so model: env("OPENAI_MODEL") on a connection fails even when the variable is unset. Each key, before and after:

BeforeAfter
ai: { reasoning: { effort: "low" } }ai: { language: { reasoning: { effort: "low" } } }
providerConfig: { openai: { apiKey, model: "gpt-4o" } }providerConfig: { openai: { apiKey } }, language: { model: "gpt-4o" }. A model on a connection only embeddings use goes to ai.embedding.model.
providerConfig: { azure: { apiKey, resourceName, modelFamily: "gpt-5" } } (or on foundry)providerConfig: { azure: { apiKey, resourceName } }, language: { modelFamily: "gpt-5" }
provider: "mistral", providerConfig: { custom: { apiKey } }provider: "mistral", providerConfig: { mistral: { apiKey } }. A provider whose id is custom keeps providerConfig.custom.
rag: { embedder: "openai", … }ai: { embedding: { provider: "openai", model: "text-embedding-3-small" } }, rag: { embedder: "ai", … }
rag: { embedder: "ai-sdk", … }ai: { embedding: { model: "text-embedding-3-small" } }, rag: { embedder: "ai", … }. The embedding provider is ai.provider unless ai.embedding.provider sets another.
rag: { embedderConfig: { openai: { model, dimension: 1536 } } }ai: { embedding: { model, dimensions: 1536 } }
rag: { embedderConfig: { openai: { apiKey, baseUrl } } }The key and base URL on a connection of the embedding provider in ai.providerConfig. When they differ from its "default" connection, add a named one and set ai.embedding.connection to its name.
rag: { embedderConfig: {} }, as earlier scaffolds wroteRemove it.
storeConfig: { pgvector: { databaseUrl, dimensions: 1536 } } with rag.embedder: "ai"ai: { embedding: { …, dimensions: 1536 } }, storeConfig: { pgvector: { databaseUrl } }
storeConfig: { pgvector: { …, dimensions } } with rag.embedder: "mock"Remove it. The mock embedder’s vectors are 64 wide unless askdb rag --dimensions asks for another width.
storeConfig: { file: { basePath, autoFlush: true } }storeConfig: { file: { basePath } }. The file store writes its files after every change.

Where the old "openai" or "ai-sdk" embedder left the model unset, it used text-embedding-3-small: set that model in ai.embedding to keep the same embeddings. An index keeps working when ai.embedding names the same provider, model and dimensions the old keys did. Otherwise Studio reports it as stale, and you rebuild it.

Read the typed ai sections instead of rt.ai.aiEnv

Section titled “Read the typed ai sections instead of rt.ai.aiEnv”

getAskDbRuntimeConfig().ai.aiEnv, ai.language.env and ai.embedding.env are removed (#711). These env maps turned the ai block into the env names @askdb/ai read back. Pass the typed section to the @askdb/ai registry instead. @askdb/client’s createAskDb already does, so a host that only calls createAskDb changes nothing.

BeforeAfter
ai.resolveAiConfig(rt.ai.aiEnv)ai.resolveAiConfigFromSection(rt.ai.language)
ai.createLanguageModelFromEnv(rt.ai.aiEnv)ai.createLanguageModelFromSection(rt.ai.language)
ai.resolveEmbeddingConfig(rt.ai.embedding.env)ai.resolveEmbeddingConfigFromSection(rt.ai.embedding)
ai.createEmbeddingModelFromEnv(rt.ai.embedding.env, { dimensions: rt.ai.embedding.dimensions })ai.createEmbeddingModelFromSection(rt.ai.embedding). It asks for dimensions when they’re set.
resolveReasoningEffort(rt.ai.aiEnv, "nlToSql")resolveReasoningEffortFromSection(rt.ai.language.reasoning, "nlToSql")
rt.ai.language.env.OPENAI_API_KEY (any rt.ai.language.env key)rt.ai.language.connection.apiKey (the matching connection setting)
rt.ai.embedding.env.OPENAI_API_KEY (any rt.ai.embedding.env key)rt.ai.embedding.connection.apiKey (the matching connection setting)
rt.ai.language.connection, rt.ai.embedding.connection (the connection name)rt.ai.language.connection.name, rt.ai.embedding.connection.name
  • connection is now an object: the connection’s name and its settings (apiKey, apiKeySecondary, baseUrl, resourceName, apiVersion), with unset ones left out. The config’s secondaryApiKey is apiKeySecondary here. rt.ai.language also gains reasoning.
  • The flat map holds no AI keys. rt.flat, flattenAskDbConfig() and defineConfig().entries no longer carry ASKDB_AI_PROVIDER, OPENAI_API_KEY, the reasoning keys or any other AI key. A child process that took its AI settings from mergeAskDbFlatIntoEnvMap() needs them another way, for example by loading the same askdb.config.*.
  • rt.rag.embedder is removed, with its type AskDbRuntimeRagEmbedderConfig. It held the OpenAI embedder’s key, base URL and model, and nothing in AskDB read it. It’s now undefined, so reading rt.rag.embedder.apiKey throws, and TypeScript reports the missing field. Read rt.ai.embedding instead: connection.apiKey, connection.baseUrl and model, for whichever provider ai.embedding names. rt.ai.embedding is undefined unless rag.embedder is "ai".
  • If you pass registry to createAskDb, build it with @askdb/ai 0.1.0-beta.11 or newer: the client calls the new section methods. With an older one, the first ask() that needs the config’s model throws ModelNotConfiguredError, which names the older @askdb/ai.
  • Without a config file, nothing changes: resolveAiConfig(env) and the *FromEnv methods read an env map as before. Env map precedence lists the variables.
RemovedUse instead
DEFAULT_OPENAI_CHAT_MODEL, DEFAULT_ANTHROPIC_CHAT_MODEL, DEFAULT_GOOGLE_CHAT_MODEL, DEFAULT_GATEWAY_CHAT_MODELDEFAULT_OPENAI_LANGUAGE_MODEL, DEFAULT_ANTHROPIC_LANGUAGE_MODEL, DEFAULT_GOOGLE_LANGUAGE_MODEL, DEFAULT_GATEWAY_LANGUAGE_MODEL
DEFAULT_RAG_EMBEDDING_MODELSet ai.embedding.model: AskDB has no default embedding model.
defaultRagEmbeddingDimensions()None: AskDB learns the width from the model (detectEmbeddingDimensions in @askdb/rag).
OpenaiConfig, AzureConfig, FoundryConfig, AnthropicConfig, GoogleConfig, GatewayConfigOpenaiConnection, AzureConnection, FoundryConnection, AnthropicConnection, GoogleConnection, GatewayConnection
OpenaiAiConfig, AzureAiConfig, FoundryAiConfig, AnthropicAiConfig, GoogleAiConfig, GatewayAiConfigAskDbAiConfig
OpenaiRagEmbedderConfigAskDbAiEmbeddingConfig (ai.embedding) and a connection type

ASKDB_RAG_EMBEDDERS is now ["mock", "ai"], and the AskDbRagEmbedder type narrows to match.

Set an embedding model on @askdb/ai’s env path

Section titled “Set an embedding model on @askdb/ai’s env path”

@askdb/ai no longer picks an embedding model when you pass an env map (#651). With an API key and no embedding model, registry.resolveEmbeddingConfig(env) and registry.createEmbeddingModelFromEnv(env) now throw <provider>: no embedding model configured. Set ai.embedding.model in askdb.config.* (or ASKDB_AI_EMBEDDING_MODEL)., as the section path already refuses a missing model. Before, openai and azure fell back to text-embedding-3-small, and gateway to openai/text-embedding-3-small, with a DeprecationWarning (code ASKDB_AI_DEFAULT_EMBEDDING_MODEL). A host with an askdb.config.* file uses the section path, so this changes nothing for it.

  • If you saw that warning, set ASKDB_AI_EMBEDDING_MODEL, or the provider’s embedding model variable, in the env map you pass, to the model the fallback used. An index built with that model keeps working.
  • If your app picks the model itself, pass it as resolveEmbeddingConfig(env, { modelDefault }). An env variable still wins over it.
  • ProviderEnvSpec has no defaultEmbeddingModel field, and the BUILTIN_AI_PROVIDERS entries no longer carry one. Remove it from a spec your custom adapter declares: TypeScript reports it as an unknown property.

Replace the askdb-rag binary with askdb rag

Section titled “Replace the askdb-rag binary with askdb rag”

@askdb/rag no longer ships the askdb-rag binary (#651). Since beta.44 it only printed the askdb rag command to run and exited 1. Now a shell or npx can’t find an askdb-rag command. Install askdb and run npx askdb rag index …, query … or setup-store … instead. Run askdb rag instead of askdb-rag maps its flags.

askdb rag --embedder openai and --embedder ai-sdk are removed (#811), as the same rag.embedder values were. Each now fails with exit code 1 and an error naming the replacement, before the command reads the schema or opens a store. Set rag.embedder: "ai" and an ai.embedding section in askdb.config.*, and pass --embedder ai or no --embedder. The aliases already embedded with ai.embedding, so an index they built keeps its embedder id and still matches.

Introspection now refuses to write an artifact with no tables (#337, ADR 0037). It used to write a schema.json with tables: [], which loadSchema() rejects, and re-introspecting a database that had lost its tables replaced a valid artifact with it.

  • askdb introspect exits 1 where it exited 0, with --out, --print and --diff alike, and writes nothing. A script that checks the exit code sees the failure now; the message names the schema id, the engine and the filters.
  • introspect() with render options, renderToSchemaV2() and renderSchemaV2Body() throw the new EmptyIntrospectionError when the schema renders no tables or views, after filters. Catch it where you introspect from code; it carries schemaId, provider and filters.
  • Studio answers 422 with the reason when its introspection or resync finds nothing, and keeps the artifact on disk.

See the troubleshooting entry for the likely causes.

Fix a bundle without a tables object, which now fails to load

Section titled “Fix a bundle without a tables object, which now fails to load”

A bundle must carry tables, the markdown of each tables/*.md keyed by file name, {} when there are none (#797). loadSchema() and loadSchemaFromJson() now throw SchemaParseError naming tables and the bundle when it’s missing, isn’t an object, or holds a value that isn’t a string. Before, a missing or null tables threw a raw TypeError, and a non-empty string or array was read as if each character or item were a file, so the error named a tables/0 that doesn’t exist. A tables of [], "", 0 or true loaded as a schema with no table files, and now throws as well. askdb bundle always writes tables, so only a bundle made or edited by hand is affected: set "tables": {}, or rebuild it with askdb bundle.

Fix a schema directory that askdb bundle now refuses

Section titled “Fix a schema directory that askdb bundle now refuses”

askdb bundle, and bundleSchemaDirectory() from @askdb/enrich, now check the bundle with the loader ask() uses (#796). bundleSchemaDirectory() throws the loader’s SchemaParseError where it returned the bundle, and askdb bundle exits 1 with that error before it writes anything. A directory the loader refuses used to bundle without complaint, and the bundle failed only when your service loaded it. Examples are a tables/*.md whose front-matter isn’t YAML, a tenant-policy.md that doesn’t parse or doesn’t fit schema.json, and two table files with the same id. Now askdb bundle exits 1, where it exited 0, and writes nothing: a file already at --out stays as it was. Fix the file the error names; if you call bundleSchemaDirectory() yourself, catch SchemaParseError there. A build step that runs askdb bundle now fails there instead of in the service.

ask() now refuses a DialectSpec whose id isn’t one of the six built-in ids, with UnknownDialectError (#717). Core lexes, quotes and binds SQL by id (ADR 0029), so a spec under another id was checked under every engine’s lexer at once: that rejected some valid column names (#672) and couldn’t read the SQL the way the real engine does. bindPreparedQuery() already refused its templates. ask() refuses such a spec before any tenant check, retrieval or model call.

  • For a variant of a built-in engine, such as TiDB on MySQL or Redshift on Postgres, spread the built-in spec and keep its id: { ...MYSQL_DIALECT, displayName: "TiDB", promptBrief: "…" }.
  • For any other engine, implement AskDialect and call validateSelectSql on its SQL yourself.
  • With a tenant policy, such a spec used to get TenantScopeError (TENANT_SQL_MODE_REQUIRED). It now gets UnknownDialectError first.

Set dialect for askdb-http’s own schema when AskDB doesn’t ship its engine

Section titled “Set dialect for askdb-http’s own schema when AskDB doesn’t ship its engine”

askdb-http no longer generates PostgreSQL for a schema whose provider has no AskDB dialect (#720). It refuses the request instead, as askdb ask does. Checking SQL for one engine under another engine’s rules can pass SQL the real engine reads differently.

  • For the server’s configured schema, POST /ask answers 500 generation_not_configured, and the server log names DialectNotSupportedError. Set dialect in askdb.config.ts to the built-in dialect your engine speaks, such as "postgres" for a PostgreSQL-compatible warehouse. That dialect applies to the server’s own schema only.
  • For a schemaJson the request sends, the server generates and checks SQL for the artifact’s own provider, never for the config’s dialect (#809). A request schema that records no provider, or one AskDB has no dialect for, answers 400 bad_request, with a message that names the provider and the supported ids. Send a schema whose provider is a built-in dialect id. A caller that relied on the server’s dialect to cover its schema’s engine, such as a MariaDB artifact, which records mysql, now gets that recorded engine’s SQL.

Pass the prompt’s schema to buildTenantPromptBlock()

Section titled “Pass the prompt’s schema to buildTenantPromptBlock()”

buildTenantPromptBlock(policy, scope, prompt) now requires prompt: the prompt’s schema and, under retrieval, the ids of the tables the retrieved DDL shows (#771, ADR 0038). The block names only those tables. ask() passes it for you, so this only matters if you call the function yourself.

  • For a prompt that shows the whole schema, pass { schema }. If no table in your schema is marked tracked: false, the block is the same as before. An untracked table can no longer be named in the block, even on purpose.
  • Under retrieval, pass { schema, tableIds }, with synthesizeRetrievedDdl()’s tableIds.
  • buildNlToSqlUserPrompt() throws AskDbError when you pass prebuiltDdl and a tenant policy without promptTableIds (the same ids), or a tenant policy with a v1 schema.
  • A connection’s secondaryApiKey is used on every provider when the connection has no apiKey. Before, only Azure and Foundry read it; the connection types still declare it only there.
  • An ai.language or ai.embedding provider that no registered adapter handles now fails with the message that says how to register it, as createLanguageModel already did, instead of Unknown ASKDB_AI_PROVIDER.
  • getAskDbRuntimeConfig().deprecations is empty, since no config key is deprecated now. It stays for the next deprecation.
  • The prompt’s note about a table the question names, but the schema doesn’t have, no longer lists the schema’s tables (#677). The list included tables marked tracked: false and, under retrieval, tables retrieval left out, so their names reached the model. The note now names only the word from the question, and the single-table note no longer names the table. Both count tracked tables only, so a question that names an untracked table gets the note too: the table stays in your schema artifact, but the prompt doesn’t list it. If you call nlToSqlAmbiguityNotes() yourself, expect the shorter notes.
  • The prompt’s tenant-policy block names only the tables the prompt shows (#771). It listed every table in your tenant policy, including tables marked tracked: false and, under retrieval, tables retrieval left out. A root whose table isn’t shown now appears by its label and placeholder only. A join path through a tracked table that retrieval left out is still printed, with that table’s name, since the shown table needs it. A scoped table whose only join path runs through an untracked table loses that path, and the prompt tells the model not to read it; the tenant check still requires its predicate.
  • A schema with a tenant policy gets 500 tenant_scope_not_configured from askdb-http, not 500 internal_error (#716). The server still can’t set a tenant scope, so it still returns no SQL and never calls the model. If your client matched internal_error for this case, match the new code.
  • Generated SQL that advances a sequence is refused (#825). On MySQL and MariaDB, NEXTVAL(…) and SETVAL(…) now fail the read-only check with SQL_FORBIDDEN_FUNCTION, and so does NEXT VALUE FOR … on every dialect: MariaDB and SQL Server advance the sequence, and the change isn’t rolled back with your transaction. PREVIOUS VALUE FOR and MariaDB’s LASTVAL() only read and still pass, as does a column named nextval.
  • askdb-http reads the working directory’s .env first, and never one inside node_modules (#839). In an npm install it used to read node_modules/.env before ./.env. An install now reads only the working directory’s .env, so if you kept your settings in node_modules/.env or the package folder, move them to the working directory’s.
  • A server schema that doesn’t load answers 500 generation_not_configured from askdb-http, not 400 schema_parse_error (#840), such as a host.schemaPath that doesn’t exist. The message no longer includes the server’s path; the server log has the full error, with errName SchemaLoadError. A request schemaJson that doesn’t load still answers 400 schema_parse_error. If your client matched schema_parse_error for a server-side schema problem, match generation_not_configured.

Released with askdb 1.0.0-beta.49, @askdb/config 1.0.0-beta.18, @askdb/core 1.0.0-beta.49, @askdb/http-api 1.0.0-beta.49, @askdb/studio 0.2.0-beta.42, @askdb/introspect 0.3.0-beta.23, @askdb/postgres 0.2.0-beta.25, @askdb/mysql and @askdb/sqlite 0.1.0-beta.24, and @askdb/sqlserver 0.1.0-beta.25. Other packages that got a new version list what changed in their CHANGELOG.

With tenantSqlMode: "sql-only" on the built-in postgres or cockroachdb dialect, ask() could return SQL whose tenant scoping no longer held once the tenant IDs were inlined, because it did not re-check the rendered statement. A model reply of a particular shape could then read rows belonging to tenants outside the caller’s scope, and ask() returned it as passing its checks (GHSA-wjgj-mf9h-6g68). ask() now runs the read-only check on the sql it returns, as bindPreparedQuery() already did, and refuses a statement that reads differently once rendered with SqlValidationError.

  • Upgrade @askdb/core, askdb and @askdb/http-api to 1.0.0-beta.49, and @askdb/studio to 0.2.0-beta.42. These pin @askdb/core exactly, so check with npm ls @askdb/core or pnpm why @askdb/core that no older copy remains under them. @askdb/client runs your app’s own @askdb/core.
  • The default tenantSqlMode: "sql-params" was never affected — the default since 1.0.0-beta.46, it binds tenant IDs as driver parameters that never enter the SQL text. If you can’t upgrade at once, use it (set it explicitly on 1.0.0-beta.45 or earlier).
  • Enforce tenant isolation in the database (for example Postgres row-level security), as Run generated SQL safely recommends. It limits the rows a statement can read regardless of its text.

Handle the new SensitiveScopeIssue members

Section titled “Handle the new SensitiveScopeIssue members”

The sensitive check now fails closed on two Postgres forms it can’t read, with the new SensitiveScopeIssue members UNICODE_ESCAPED_IDENTIFIER (a name such as U&"p\0061ssword") and STRING_CONTINUATION (an E'…' string continued on a later line) (#707, ADR 0012). They apply whenever the SQL may be read as Postgres, a custom AskDialect included, and come back in sensitiveGuardrail.unresolvedScope with passed: false. With sensitiveGuardrailMode: "strict", ask() and validateSensitiveReferences() throw SensitiveReferenceError.

  • A switch over every SensitiveScopeIssue needs cases for both.
  • Built-in dialects reject the same SQL earlier in the read-only check (SQL_UNICODE_ESCAPED_IDENTIFIER, SQL_STRING_CONTINUATION), so this mainly changes a custom AskDialect and direct calls.

Handle SQL_ESCAPED_BYTE_STRING for escaped b'…' byte strings

Section titled “Handle SQL_ESCAPED_BYTE_STRING for escaped b'…' byte strings”

CockroachDB reads a b'…' byte string (lower-case b) with backslash escapes, and AskDB’s checks don’t, so a byte string with a backslash or a continuation could hide code from them (#730, ADR 0012). The checks now refuse it:

  • The read-only check throws SqlValidationError with the new SqlValidationRuleCode SQL_ESCAPED_BYTE_STRING, on Postgres, CockroachDB and a dialect id that is no built-in engine. So do ask(), bindPreparedQuery() and Studio’s Execute.
  • The tenant check reports the new TenantGuardrailRuleCode ESCAPED_BYTE_STRING, with tableId: "", whenever the SQL may be read as Postgres, a custom AskDialect included, which skips the read-only check. Your policy’s enforcement decides whether it throws.
  • The sensitive check reports the new SensitiveScopeIssue ESCAPED_BYTE_STRING in unresolvedScope, with passed: false, under the same dialects as the tenant check, and in a validateSensitiveReferences() call with no dialect.
  • A switch over any of these unions needs a case for the new member.
  • Write the bytes as a hex string cast to bytea ('\x00'::bytea instead of b'\x00'), which Postgres and CockroachDB both read as bytes. A b'…' string with no backslash, and a B'…' bit string, pass.

See the troubleshooting entry.

  • askdb init and Studio’s guided setup install with pnpm in a new pnpm project (#750). With no lockfile, both used npm, which refuses a project that pnpm init started (EBADDEVENGINES). They now read package.json’s packageManager field, then devEngines.packageManager, then the tool that ran them (pnpm dlx, yarn dlx, npx, bunx), before falling back to npm. A project with a lockfile installs with that lockfile’s manager, as before. See askdb init for the order.
  • askdb init installs on Windows (#668). Its install always failed there, because Node refuses to run a package manager’s .cmd shim without a shell.
  • The install command askdb init prints names the package manager it picked. At a workspace root, or with no package.json, it printed npm install --save …. It now prints that manager’s command, with --allow-build=@prisma/engines for a Prisma project on pnpm 10.4 or later. A script that matched the old text needs updating.
  • @askdb/config/scaffold is experimental (ADR 0030). It now also exports the package-manager helpers askdb init and Studio share, and planAskDbScaffold, which plans a new project’s config, .env.example and installs for both (#711). A minor release of @askdb/config may change it. Your own askdb.config.ts never imports it.
  • askdb init checks the env var names and paths you pass. askdb init --connection-env my-url wrote env("my-url"), a name a shell can’t export. A name for --connection-env, --ai-key-env, --ai-model-env or --pgvector-env must now use uppercase letters, digits and underscores, and not start with a digit, and a path must not contain control characters. Otherwise init exits 1 before it writes anything, with an error that names the flag. See askdb init.
  • askdb init --database prisma also installs ai and zod (#760): ai at the newest major @askdb/core accepts, and zod at core’s range. Under Yarn Plug’n’Play, askdb introspect --engine prisma failed without them (@askdb/core tried to access ai). Like a database driver, init now installs them only when your package.json doesn’t list them yet: a project on ai@^6 keeps it, and init no longer re-adds a driver you already list. Studio’s guided setup installs them too when the project doesn’t have them.
  • .env.example leaves every value empty. askdb init wrote each connection URL’s format as its value (DATABASE_URL=postgresql://<USERNAME>:…), and Studio wrote NAME= # purpose, which Docker’s --env-file reads as the value. Both now write a # purpose comment above each empty NAME=, database variables first, with a connection URL’s format in the comment.
  • Studio’s guided setup writes the same files as askdb init for the same answers. Its config’s rag.storeConfig spans several lines, as init’s does; the config loads the same. The variables it lists come in init’s order, database variables first, and say what each one is for, such as postgres connection URL, for introspection and Studio execute. It now accepts an env var name that starts with _.
  • A connector from createConnector() returns the schemaId you pass in ConnectorConfig (#775). Only the Prisma adapter passed it on, so Postgres, MySQL, SQLite and SQL Server returned a SqlSchema with schemaId: "introspected". If your code reads SqlSchema.schemaId from these connectors, it now gets the ID you set. schema.json doesn’t change: it takes its ID from RenderOptions.schemaId, so askdb introspect and Studio write the same files.
  • askdb init --connection-env <name> --studio-execute points Studio execute at that variable (#777). It wrote studio.execute.databaseUrl: env("DATABASE_URL") whatever --connection-env named, so execute ran against another database unless DATABASE_URL was also set. A config written by an earlier init keeps DATABASE_URL; change it to your variable if it differs.
  • A model call that fails before saying why now names the reason (#786). When the model’s server refuses the connection, such as a local OpenAI-compatible server that isn’t running, reached through localhost, SqlGenerationError’s message and the askdb.pipeline.failed log ended in a blank: Model call failed: … Cannot connect to API: . They now end with each refused address, such as ECONNREFUSED [::1]:11434; ECONNREFUSED 127.0.0.1:11434, built from each error’s code, address and port (or its name when it has no code), never from text that could quote a URL or key. A message that already gives its reason is unchanged.

Released with @askdb/client 1.0.0-beta.11, @askdb/config 1.0.0-beta.17 and @askdb/core 1.0.0-beta.48. Other packages that got a new version list what changed in their CHANGELOG.

Fix config values of the wrong type, which now fail to load

Section titled “Fix config values of the wrong type, which now fail to load”

@askdb/config now checks each setting once, when the config loads, and builds every runtime value from your config alone (#711). A value of the wrong type fails to load in every tool that loads the config, with an error that names the file and the config key.

A boolean setting must be true or false. logging.logStdout and modes.omitSensitiveFromPrompt now fail on any other value, as studio.execute.enabled, studio.execute.useIntrospectionConnection and httpApi.allowSchemaOverride already did. A string here was read wrong before, without a warning:

In your configBeforeNow
logging: { logStdout: "true" }Loaded, and didn’t copy the logs to stdout: only true turned it on.Fails: AskDB config at /app/askdb.config.ts: logging.logStdout must be a boolean (got "true").
modes: { omitSensitiveFromPrompt: "false" }Loaded, and silently turned omission on: any non-empty string counted as true.Fails: AskDB config at /app/askdb.config.ts: modes.omitSensitiveFromPrompt must be a boolean (got "false").

To read one from the environment, compare the string: omitSensitiveFromPrompt: env("OMIT_SENSITIVE") === "true".

A string setting must be a string, or null for unset, such as logging.logFile, host.schemaPath or a databaseUrl. Any other value fails, false and 0 included. Before, AskDB ignored false or 0 for a few keys, such as logging.logFile and dev.mockSql, and stopped the load with a TypeError that named no key for the rest. Write undefined or null to leave a string setting unset.

Code that reads or installs the runtime config may need a change too:

  • getAskDbRuntimeConfig().logging.level is AskDbLogLevel | undefined, and modes.askdbMode is AskDbModeV1 | undefined, not string. Code that builds an AskDbRuntimeConfig by hand with a string, for example in a test, no longer typechecks. Type the value with the union, or cast one you know is valid:

    import type { AskDbLogLevel, AskDbModeV1 } from "@askdb/config";
    const level: AskDbLogLevel = "debug"; // was: const level: string = "debug"
    const modes = { askdbMode: mode as AskDbModeV1, omitSensitiveFromPrompt: false };
  • setAskDbRuntimeForTests() takes an AskDbConfig, the object you write in askdb.config.ts, and installs it as bootstrapAskDbEnv() installs a loaded file. Replace setAskDbRuntimeForTests({ structured, flat: flattenAskDbConfig(structured) }) with setAskDbRuntimeForTests(structured). If a test put a setting only in flat, set it in the config instead, such as dev: { mockSql } in place of ASKDB_MOCK_SQL.

  • createAskDb() reads the default schema from host.schemaJson, then host.schemaPath, and nowhere else. A runtime config built by hand with ASKDB_SCHEMA_JSON or ASKDB_SCHEMA_PATH in ai.aiEnv and no host block now throws SchemaNotConfiguredError: set structured.host instead. A config loaded from a file works as before.

Run unboundSql with the tenant IDs in params under "sql-only"

Section titled “Run unboundSql with the tenant IDs in params under "sql-only"”

tenantSqlMode now governs sql only (#731). Under "sql-only", ask() used to inline the tenant IDs into unboundSql and leave them out of params, while bindPreparedQuery() and askdb.bind() bound them. Now ask() binds them too: unboundSql holds driver markers for the tenant IDs, and params holds their values, as in "sql-params" mode. sql is unchanged, with the IDs inlined as literals.

// ask({ …, tenantScope: { access: { kind: "ids", tenantRoot: "table:public.agencies", ids: ["42"] } }, tenantSqlMode: "sql-only" })
result.sql // SELECT * FROM orders WHERE agency_id = '42' AND status = 'open' (unchanged)
result.unboundSql // SELECT * FROM orders WHERE agency_id = '42' AND status = $1
result.params // ["open"]
result.unboundSql // SELECT * FROM orders WHERE agency_id = $2 AND status = $1
result.params // ["open", "42"]

If you run unboundSql with params, you need no change: the pair still returns the same rows. If you run unboundSql with your own array of business values, switch to params before you upgrade, and check every place that runs unboundSql. With your array, the values no longer line up with the markers: the tenant markers come first in source order on ? dialects, so your first business value can land on a tenant marker. The drivers AskDB documents (pg, mysql2, mssql, better-sqlite3) refuse the short array, so the query fails. node:sqlite doesn’t check the number of values it binds: it binds them in order and the rest as NULL, which can return another tenant’s rows. Always pass params as returned. If you need one SQL string with the IDs inlined, use sql.

Handle SQL_STRING_CONTINUATION for continued E'…' strings

Section titled “Handle SQL_STRING_CONTINUATION for continued E'…' strings”

On Postgres, CockroachDB, and a dialect id that is no built-in engine, validateSelectSql() now rejects an E'…' string continued on a later line (E'a', a newline, then 'b'), and so do ask(), bindPreparedQuery() and Studio’s Execute. It throws SqlValidationError with the new rule code SQL_STRING_CONTINUATION (#703, ADR 0012). Postgres reads the continuation with backslash escapes and the checks didn’t, so code after it could pass as a string.

  • The tenant check refuses the same SQL with the new TenantGuardrailRuleCode STRING_CONTINUATION and tableId: "", whenever the SQL may be read as Postgres, a custom AskDialect included. Your policy’s enforcement decides whether it throws or warns.
  • A switch over every SqlValidationRuleCode or TenantGuardrailRuleCode needs a case for the new code.
  • If you store SQL that continues an E'…' string and rebind it, the rebind now rejects it. Join the parts with || instead.

See the troubleshooting entry.

  • A malformed schema markdown file reports the same error on every load (#742). Before, when one process loaded the same malformed front-matter YAML in tables/*.md, concepts.md or tenant-policy.md a second time, it reported Invalid <kind> front-matter (missing fields) instead of Malformed <kind> front-matter YAML. Loading failed both times. @askdb/core also no longer keeps every schema markdown file it reads or writes in memory for the life of the process, so a server that accepts per-request schemas (httpApi.allowSchemaOverride) no longer grows with each new one.
  • A schema markdown body that starts with a --- horizontal rule survives a save (#742). Before, writeTableMarkdown(), writeConceptsMarkdown() and writeTenantPolicyMarkdown(), and the Studio saves that use them, read the rule as front-matter: the body was emptied, and the file then failed to load.
  • Schema markdown front-matter accepts YAML only (GHSA-qqx9-qpw2-wcqg). A tables/*.md, concepts.md or tenant-policy.md file whose front-matter isn’t YAML now fails to load with SchemaParseError (Malformed <kind> front-matter YAML). Front-matter that opens with a plain ---, as Studio and @askdb/enrich write it, loads as before.
  • The tenant check reads a dialect id that is no built-in engine and sets backslashEscapes: true both ways when it looks for U&"…" names (#703): with and without backslash escapes, reporting a name either reading finds.

Released with @askdb/config 1.0.0-beta.16 and @askdb/core 1.0.0-beta.47. Other packages that got a new version list what changed in their CHANGELOG.

Replace the removed DEFAULT_RAG_FILE_BASE_PATH import

Section titled “Replace the removed DEFAULT_RAG_FILE_BASE_PATH import”

@askdb/config no longer exports DEFAULT_RAG_FILE_BASE_PATH (#676). Nothing read it, and its value, ./askdb/rag, was never the file store’s default. If your app imports it, it no longer typechecks or loads.

PackageRemoved exportUse instead
@askdb/configDEFAULT_RAG_FILE_BASE_PATHYour own path. With rag.storeConfig.file.basePath unset, the CLI and Studio use <schema dir>/schema, as before.

Four codes are gone from @askdb/core’s error-code types, because none of AskDB’s own checks reported them (#682, ADR 0033): INCOMPATIBLE_JOIN_SCOPES and CROSS_TENANT_WITHOUT_GLOBAL from TenantGuardrailRuleCode, and INVALID_NAME and RESERVED_NAME from QueryParameterRejectionReason. Only code that names them breaks, and TypeScript reports where: delete those case branches and comparisons. A custom AskDialect that reported one of the two tenant rules in tenantGuardrail.warnings reports MISSING_TENANT_PREDICATE (a table read without its tenant predicate) or UNPROVABLE_SCOPE instead.

A global tenant scope whose reason is missing, isn’t a string, or is blank now throws TenantScopeError with GLOBAL_WITHOUT_REASON, not INVALID_SCOPE_SHAPE. A blank reason, such as " ", passed before. Give every global scope a reason that says why it sees every tenant, and match GLOBAL_WITHOUT_REASON where you handled INVALID_SCOPE_SHAPE for it.

Handle SQL_UNICODE_ESCAPED_IDENTIFIER for Unicode-escaped names

Section titled “Handle SQL_UNICODE_ESCAPED_IDENTIFIER for Unicode-escaped names”

On Postgres and CockroachDB, validateSelectSql() now rejects a Unicode-escaped identifier such as U&"set\005fconfig", and so do ask(), bindPreparedQuery() and Studio’s Execute. It throws SqlValidationError with the new rule code SQL_UNICODE_ESCAPED_IDENTIFIER (#692, ADR 0012). Postgres decodes the escapes, which could spell a blocked function, such as set_config, a scoped table (U&"\006Frders" reads orders) or a sensitive column, and the checks didn’t decode them.

  • The tenant check refuses the same names, in any position, with the new TenantGuardrailRuleCode UNICODE_ESCAPED_IDENTIFIER and tableId: "" (#704). It applies whenever the SQL may be read as Postgres, a custom AskDialect included, which skips the read-only check. Your policy’s enforcement decides whether it throws or warns.
  • A switch over every SqlValidationRuleCode or TenantGuardrailRuleCode needs a case for the new code.
  • If you store SQL with a U&"…" name and rebind it, the rebind now rejects it. Write the name in plain double quotes ("注文数") instead.

See the troubleshooting entry and Safety boundaries.

Return only queries from a custom AskDialect under a tenant policy

Section titled “Return only queries from a custom AskDialect under a tenant policy”

With a tenant policy and any scope but global, the tenant check now refuses a statement that doesn’t start with SELECT or WITH, under every dialect (#698). Before, a custom AskDialect could return a DO block, CALL, EXPLAIN or SHOW, and the check passed it: it reads a DO body as a dollar-quoted string, so it saw no tenant-scoped table, and the block could read every tenant’s rows. Now ask() throws TenantGuardrailError under enforcement: strict, and returns a NOT_SELECT_OR_WITH warning in tenantGuardrail under warn. Each statement after a ; counts too, and on SQL Server each one a verb such as EXEC starts with no ;. A leading comment or parenthesis doesn’t hide the first keyword. The same check runs when you rebind a stored template with bindPreparedQuery() or askdb.bind(), so a template with such a statement is refused there too (under warn, unless you pass acceptWarnings: ["tenant"]).

  • If your custom AskDialect returns queries, you have nothing to change. Built-in dialect ids and DialectSpecs already reject other statements in the read-only check (SQL_NOT_SELECT_OR_WITH).
  • If it returns another statement on purpose for a schema with a tenant policy and a scope other than global, ask() no longer returns it under strict. Generate a SELECT or WITH query instead.
  • Code that handles every TenantGuardrailRuleCode, such as an exhaustive switch, needs a case for NOT_SELECT_OR_WITH.

Expect UNCHECKABLE_FUNCTION for calls the tenant check can’t read through

Section titled “Expect UNCHECKABLE_FUNCTION for calls the tenant check can’t read through”

With a tenant policy and any scope but global, the tenant check now refuses a function whose reads it can’t see, wherever the SQL names it, under every dialect (#702). That is a function that runs a query passed as a string (query_to_xml, ts_stat, crosstab), reads a table named in a string (table_to_xml, schema_to_xml, connectby), runs SQL over another connection (dblink, OPENQUERY), or sets a value row-level security may read (set_config). The full list is in the tenant policy contract. Before, the check passed SELECT table_to_xml('orders', true, false, '') under the built-in Postgres dialect, and it read every tenant’s orders. Now ask() throws TenantGuardrailError under enforcement: strict, and returns an UNCHECKABLE_FUNCTION warning under warn. bindPreparedQuery() and askdb.bind() refuse a stored template with such a call too (under warn, unless you pass acceptWarnings: ["tenant"]).

  • A pivot the model writes with crosstab is refused under a tenant policy, because the check can’t read the query crosstab runs. Re-ask the question: a pivot written with CASE or FILTER passes, with the tenant predicate in its WHERE clause.
  • A column, alias or parameter named like one of these functions (crosstab, set_config) is refused too, because the check matches a listed name anywhere in the SQL. If your schema has such a column, every query that names it is refused under a tenant policy: rename the column, or expose it through a view under another name.
  • Code that handles every TenantGuardrailRuleCode, such as an exhaustive switch, needs a case for UNCHECKABLE_FUNCTION.
  • httpApi.listen.port is checked when the config loads (#676). A value that isn’t an integer from 0 to 65535, or a string holding one, fails with askdb.config: invalid httpApi.listen.port, in every tool that loads the config. Before, the HTTP server passed such a value straight to listen(). A value that reads as NaN, such as Number(env("PORT")) with PORT unset, now counts as unset: the server uses port 3000 instead of failing to start.
  • rag.storeConfig.file.autoFlush gives a deprecation warning when the config loads (#676). Nothing ever read it, because the file store writes its files after every change. Remove it.
  • logging.logFile stays open for the life of the process (#693). createAskDbLogger() opens each log file once and shares it across loggers, so the HTTP API server no longer leaks a file descriptor on every POST /ask. After a rename or delete, logs keep going to the old file: rotate it with copytruncate, or restart the process after a rotate.

Released with @askdb/client 1.0.0-beta.9, @askdb/config 1.0.0-beta.15, @askdb/enrich 0.2.0-beta.17, @askdb/introspect 0.3.0-beta.20, @askdb/mysql 0.1.0-beta.21, @askdb/prisma 0.2.0-beta.20, @askdb/sqlite 0.1.0-beta.21, @askdb/sqlserver 0.1.0-beta.22 and @askdb/studio 0.2.0-beta.39. Other packages that got a new version list what changed in their CHANGELOG.

These exports are gone, because nothing outside their own package used them and no page documented them (#356, #275, ADR 0031). If your app imports one, it no longer typechecks or loads. Replace each one:

PackageRemoved exportUse instead
@askdb/coreformatSchemaForPrompt(schema, options)formatSchemaForNlToSql(schema, options).ddl, which returns the same string
@askdb/configDEFAULT_LOCAL_POSTGRES_URLYour own local connection string
@askdb/enrichparseListInput(value)value.split(",").map((s) => s.trim()).filter(Boolean)
@askdb/enrichformatList(list)list?.join(", ") ?? ""
@askdb/enrichhasAnyColumnDescribable, isEnumCandidateNothing
@askdb/mysqlMYSQL_CATALOG_SQLNothing: createMysqlConnector() runs the catalog queries
@askdb/sqliteSQLITE_CATALOG_SQLNothing: createSqliteConnector() runs the catalog queries
@askdb/sqlserverSQLSERVER_CATALOG_SQLNothing: createSqlServerConnector() runs the catalog queries

@askdb/postgres keeps POSTGRES_TEMPLATE_BUNDLE, because askdb introspect --from-export has you run its templates against the database by hand first. askdb introspect templates --engine postgres prints them.

Run tenant-scoped SQL with its tenantParams

Section titled “Run tenant-scoped SQL with its tenantParams”

ask() now binds tenant IDs as driver parameters by default (tenantSqlMode: "sql-params", #231, ADR 0024). If you pass tenantScope without tenantSqlMode, result.sql holds your dialect’s driver markers ($1, ? or @p0) where the tenant IDs used to be inlined, and the IDs are in result.tenantParams. Run each SQL form with its own params array:

// Before
await conn.execute(result.sql);
// After
if (result.unboundSql && result.params) {
await conn.execute(result.unboundSql, result.params); // params already holds the tenant IDs
} else {
await conn.execute(result.sql, result.tenantParams ?? []);
}
  • Never concatenate params and tenantParams. Running the new sql without its params fails at the driver (Postgres: there is no parameter $1; better-sqlite3: Too few parameter values; MySQL: a syntax error). It never returns other tenants’ rows.
  • To keep the IDs inlined, set tenantSqlMode: "sql-only".
  • A custom AskDialect, or a DialectSpec whose id isn’t built in, has no default. With a tenant scope and no tenantSqlMode, ask() throws TenantScopeError (TENANT_SQL_MODE_REQUIRED) before the model call. Set "sql-params" if your driver binds $1-style markers, or "sql-only": on SQL Server, $1 is a money constant. A value other than these two throws the same error on every dialect.
  • @askdb/client’s askdb.ask() forwards to ask(), so it changes the same way. Studio’s Playground now starts in sql-params.

Executing the result describes both SQL forms.

Use sql-params for tenant IDs outside printable ASCII

Section titled “Use sql-params for tenant IDs outside printable ASCII”

Wherever AskDB inlines a tenant ID, the ID must now be printable ASCII without a backslash, on every dialect (#360, #371, ADR 0024). That covers tenantSqlMode: "sql-only", in ask() and in a rebind. Any other ID throws TenantScopeError (UNESCAPABLE_TENANT_ID). Before, such an ID could end its literal early on a server whose string settings or connection charset differ from the dialect’s, and the query lost its tenant filter.

  • Use tenantSqlMode: "sql-params", the default in ask() and in a rebind, which passes any ID as a driver parameter.
  • A control character in a tenant ID now throws this TenantScopeError instead of QueryParameterError.

Update your bindPreparedQuery() and askdb.bind() calls

Section titled “Update your bindPreparedQuery() and askdb.bind() calls”

A rebind changes three ways in this release. Update each call site once:

const hostSpec = { ...POSTGRES_DIALECT, backslashEscapes: true }; // only if you pass your own spec to ask()
// Before
const rebound = bindPreparedQuery(stored, { state_name: "Utah" }, { schema, tenantScope });
await pool.query(rebound.sql);
// After
const rebound = bindPreparedQuery(stored, { state_name: "Utah" }, {
schema,
tenantScope,
dialect: hostSpec, // the spec you gave ask(), or a built-in id such as "postgres" for a template stored before beta.46
});
await pool.query(rebound.sql, rebound.tenantParams ?? []); // or: pool.query(rebound.unboundSql, rebound.params)
  • Run the rebound sql with tenantParams (#664, ADR 0024). A rebind now takes tenantSqlMode, with the same default as ask(). With a tenant scope, its sql keeps the business values inlined and holds the tenant IDs as driver markers, and the IDs are in the new tenantParams. Running sql without them fails at the driver; it never returns other tenants’ rows. To keep inlined IDs, pass tenantSqlMode: "sql-only". Without a tenant policy, sql doesn’t change.
  • Tenant IDs outside printable ASCII, such as agência, now rebind in the default mode. In "sql-only", they throw UNESCAPABLE_TENANT_ID, as an ID with a backslash does.
  • Pass dialect when ask() ran with your own DialectSpec (#660, ADR 0032). A rebind now checks and renders under the dialect rules ask() ran with, which ask() records in preparedQuery.dialectRules. Before, it always used the built-in spec, so your backslashEscapes, blockedFunctions, extraForbiddenKeywords and extraValidate were dropped at rebind. A "host" template, even from an unchanged spread, throws QueryParameterError (DIALECT_SPEC_REQUIRED) without dialect.
  • A template stored before this release, or built by hand, has no dialectRules, so it throws the same error until you pass dialect. If ask() ran with a built-in id, the id is enough, such as dialect: "postgres".
  • A dialect with another id than the template’s throws QueryParameterError (DIALECT_UNSUPPORTED).
  • An app that loads two copies of @askdb/core, such as ESM beside CJS, gets "host" templates, because the other copy’s constants are different objects. Pass dialect.
  • askdb.bind() takes the same tenantSqlMode and dialect options.

Fix a strict tenant policy that doesn’t match schema.json

Section titled “Fix a strict tenant policy that doesn’t match schema.json”

loadSchema() now checks every ID in tenant-policy.md against schema.json, and a policy with enforcement: strict must match it (#332, ADR 0023). It throws SchemaParseError when the policy names a column or foreign key that schema.json doesn’t have, has a cycle in hierarchy or parent, or names a root in parent, hierarchy, scopeThrough or a polymorphic mapping that roots doesn’t declare. Before, the policy loaded, and only Studio’s Tenancy page showed the problems. The error names tenant-policy.md and each ID: update the policy to match schema.json. See When the policy and the schema disagree.

  • Every caller gets the same underlying error: loadSchema() throws the SchemaParseError, @askdb/client wraps it in SchemaLoadError as its cause, and the HTTP API answers 400 schema_parse_error. Studio can’t open the workspace until you fix the file (#640).
  • Under enforcement: warn, the policy still loads, with the problems in schema.tenantPolicy.warnings. ask() now logs them once per loaded schema as askdb.tenant.policy_warnings, through the logger you pass to it.
  • A table that schema.json doesn’t have stays a warning in both modes. Its policy entry now reports only the table, not each of its columns.

Build enrichment targets from loadSchema()’s tables

Section titled “Build enrichment targets from loadSchema()’s tables”

EnrichmentTarget.table and EnrichmentContext.neighbors now take NormalizedV2Table, the table type in loadSchema()’s result, instead of V2Table, the table as schema.json stores it (#667, ADR 0026). Before, a table read straight from schema.json lacked front-matter sensitivity. With omitSensitiveIdentifiersFromPrompt on, a column marked sensitive only in tables/*.md was named in the prompt, and a suggestion for it wasn’t refused. If your app passes such a table to suggestEnrichment() or buildEnrichmentUserPrompt(), it no longer typechecks. Build the target and context from loadSchema()’s result:

// Before
const table = schemaJson.tables.find((t) => t.id === tableId); // a V2Table
await suggestEnrichment({ kind: "table-description", table }, { schemaId: schemaJson.schemaId }, model, deps);
// After
import { loadSchema, suggestEnrichment } from "@askdb/core";
import { buildSuggestionContext, buildSuggestionTarget } from "@askdb/enrich";
const schema = loadSchema("./my-app.schema");
const source = { scope: "table", tableId, field: "description" } as const;
await suggestEnrichment(buildSuggestionTarget(schema, source), buildSuggestionContext(schema, tableId), model, deps);
  • If you build both with @askdb/enrich’s buildSuggestionTarget() and buildSuggestionContext(), or use Studio, you have nothing to change.
  • A table you build by hand must set sensitive on the table, and sensitive and primaryKey on every column. Mark sensitive each column that tables/*.md marks, and every column of a sensitive table, as loadSchema() does.
  • loadSchemaFromJson() of a bare schema.json typechecks too, but has no front-matter flags. Load the schema directory or a bundle with loadSchema() instead.

loadSchema() now throws SchemaParseError when a schema directory holds a file whose name differs from the expected one only in case, such as Tenant-Policy.md, Concepts.md, Tables/ or tables/orders.MD (#608, ADR 0027). Before, Linux skipped those files: a Tenant-Policy.md loaded the schema with no tenant policy, so ask() didn’t require a tenantScope, and nothing reported it. A tables/*.MD file was skipped on every OS, with every sensitive: true in it. The check now runs on every OS, so a directory that loaded on macOS or Windows with such a name throws there too.

  • Rename each file the error names. The error says what to do, for example rename Tenant-Policy.md to tenant-policy.md. When both spellings exist, which only Linux allows, it says to keep one.
  • A listed optional file that can’t be read now throws, including a symbolic link whose target is missing. Before, it loaded as if the file were absent.
  • A renamed copy of schema.json beside a wrong-case Tenant-Policy.md now throws, as it already did beside tenant-policy.md.
  • askdb bundle and bundleSchemaDirectory() refuse the same directories, and their read errors are now SchemaParseErrors instead of raw Node errors. Studio can’t open such a workspace until you rename the file.
  • A path you pass is still read as given, so loadSchema("<dir>/Schema.json") loads as before.

See the troubleshooting entry.

Install @askdb/prisma to introspect a Prisma schema

Section titled “Install @askdb/prisma to introspect a Prisma schema”

askdb and @askdb/studio no longer install @askdb/prisma: it’s now an optional peer dependency (#259). So pnpm add askdb succeeds on pnpm 11, which refused Prisma’s unapproved @prisma/engines build and exited 1. If you introspect a Prisma schema, install @askdb/prisma next to askdb:

Terminal window
npm install @askdb/prisma
  • On pnpm 11, approve the @prisma/engines build in the same command, pnpm add --allow-build=@prisma/engines @askdb/prisma, or run pnpm approve-builds afterwards.
  • Without it, askdb introspect --engine prisma exits 1 with a message that names the package, and Studio shows the same message instead of resyncing. askdb looks for @askdb/prisma in its own install, then in the project it runs in, so npx askdb introspect uses your project’s copy.
  • askdb init --database prisma installs it for you.
  • If you build your own connector registry, createConnectorRegistry() also takes lazy entries ({ provider, load }) now, which the new registry.load(provider) imports. A registry of adapters works as before.

See the Prisma guide.

validateSelectSql, and so ask(), generateSelectSql() and bindPreparedQuery(), now refuses locking reads on every engine, with the new rule code SQL_LOCKING_READ (#319). Before, FOR SHARE, LOCK IN SHARE MODE and SQL Server’s WITH (UPDLOCK) passed, and your transaction then held their locks.

  • FOR UPDATE and FOR NO KEY UPDATE now throw SQL_LOCKING_READ instead of SQL_FORBIDDEN_KEYWORD. If your code matches the old rule code for them, match the new one.
  • Newly refused: FOR SHARE, FOR KEY SHARE and LOCK IN SHARE MODE on every engine, and the SQL Server table hints UPDLOCK, XLOCK, HOLDLOCK, PAGLOCK, TABLOCK, TABLOCKX, ROWLOCK, SERIALIZABLE and REPEATABLEREAD. NOLOCK and READPAST still pass.
  • On SQL Server, an unquoted name must be printable ASCII and can’t be one of those hint words. Quote it ([Größe], [rowlock]), and qualify it when it’s the only thing inside parentheses (COUNT(t.[Größe])). The same applies to a dialect id that is no built-in engine. This narrows beta.45’s plain-code rule, which let Unicode letters through.
  • A switch over every SqlValidationRuleCode needs a case for SQL_LOCKING_READ.

See the troubleshooting entry and Safety boundaries.

Every AskDB write into a schema directory now refuses symbolic links (#617, ADR 0028). When schema.json is one, askdb introspect --out, renderToSchemaV2() and introspect() with render options throw Refusing to write schema.json outside the schema directory: "schema.json" is a symbolic link. Before, they wrote to the file the link pointed at. Replace the link with a regular file.

  • Studio refuses a linked playground-history.json the same way. It leaves a linked .gitignore as it is.
  • Writing schema.json needs write access to the output directory, because AskDB writes a temporary file there and renames it over the target. A hard link at schema.json is replaced instead of written through. The new file keeps the old one’s permission bits and group where it can, but not an access control list. Only root can give a file to another user, so on macOS and Linux a save by anyone other than the file’s owner makes the saver its owner. On Windows, the rename fails with EPERM while another process holds schema.json open; run the command again.
  • If you match @askdb/enrich’s table-save error, it now reads Refusing to write tables/orders.md outside the schema directory: ….
  • Config files: askdb.config.mjs and a plain export default { … } now load, as the docs said (#264, #265). A config with no ai or introspection block, no default export, or top-level await fails with an error that names the file and what to change, instead of a bare TypeError.
  • Studio: a studio.listen.port that is set but isn’t an integer from 1 to 65535 now stops Studio with an error. Before, Studio bound port 5556 instead. A value that reads as NaN, such as Number(env("STUDIO_PORT")) with the variable unset, still counts as unset.
  • askdb introspect --out checks an existing tenant-policy.md against the schema.json it just wrote. On stderr, it prints what no longer matches, or, under enforcement: strict, that the schema no longer loads. The exit code stays 0, so read stderr. Under strict, fix tenant-policy.md before anything loads the new schema. Under warn, the schema loads, with the problems in schema.tenantPolicy.warnings.

Released with @askdb/client 1.0.0-beta.8, @askdb/ai 0.1.0-beta.9, @askdb/config 1.0.0-beta.14, @askdb/enrich 0.2.0-beta.16, @askdb/introspect 0.3.0-beta.19 and @askdb/studio 0.2.0-beta.38. Every other package got a new version too; its CHANGELOG lists what changed.

Replace the removed @askdb/ai-* and @askdb/connectors packages

Section titled “Replace the removed @askdb/ai-* and @askdb/connectors packages”

@askdb/ai-openai, @askdb/ai-azure, @askdb/ai-google, @askdb/ai-anthropic and @askdb/connectors are no longer published (#347). Their last versions stay on npm, so an existing install keeps working, but it gets no fixes. Uninstall the one you use, and install @askdb/ai and the AI SDK package for your provider instead:

Terminal window
npm install @askdb/ai @ai-sdk/openai

Then drop the old import:

import { openaiProvider } from "@askdb/ai-openai";
const askdb = createAskDb({ config, providers: [openaiProvider] });
const askdb = createAskDb({ config }); // or providers: ["openai"]
const ai = createAiRegistry([openaiProvider]);
const ai = createAiRegistry(["openai"]);

If you import from @askdb/connectors, import from @askdb/introspect and @askdb/introspect/kit instead. CONNECTOR_PROVIDERS is now BUILT_IN_CONNECTOR_PROVIDERS, and ConnectorProvider is now ConnectorProviderId. Removed packages lists each package’s last version and its replacement.

Pass the schema and tenant scope to bindPreparedQuery()

Section titled “Pass the schema and tenant scope to bindPreparedQuery()”

bindPreparedQuery() now re-runs the guardrails on a stored template before it binds new values (#310, ADR 0010). It takes a required third argument with the schema, and it reads tenant IDs from a tenant scope instead of from values:

// Before
const rebound = bindPreparedQuery(result.preparedQuery!, {
state_name: "Utah",
":tenant_agency_ids": authorizedAgencyIds,
});
// After
const rebound = bindPreparedQuery(
result.preparedQuery!,
{ state_name: "Utah" },
{ schema, tenantScope }, // the same schema and the caller's scope, as for ask()
);
  • Without the third argument, it throws QueryParameterError (MISSING_GUARDRAIL_CONTEXT). Without a tenant policy, { schema } is enough.
  • A :tenant_* key in values throws QueryParameterError (TENANT_VALUE_SUPPLIED). Pass the IDs in tenantScope. Expand a subtree scope with expandTenantScope() first.
  • A template with a :tenant_* placeholder can’t be rebound under a global scope, or with a schema that has no tenant policy. Both throw TenantScopeError (UNRESOLVED_TENANT_PLACEHOLDER).
  • With a tenant policy whose enforcement is warn, a template that fails the tenant check is refused with TenantGuardrailError. To bind it anyway, pass acceptWarnings: ["tenant"].
  • Stored PreparedQuery objects keep working. The checks use the schema and scope you pass now, so a tightened policy applies at the next rebind.

With @askdb/client, call askdb.bind(prepared, values, { tenantScope }) instead. It supplies the client’s schema and expands a subtree scope for you. bindPreparedQuery() lists every error it throws.

Load a schema directory, not a renamed copy of its JSON file

Section titled “Load a schema directory, not a renamed copy of its JSON file”

A JSON file inside a schema directory must be named schema.json (#330, ADR 0019). If you pass loadSchema() any other JSON file that has tenant-policy.md, tables/ or concepts.md beside it, such as prod-schema.json, it now throws SchemaParseError. Before, it loaded the file without the tenant policy and without the sensitive: true marks in tables/*.md. A bundle, a JSON file with bundled: true such as prod.bundle.json, still loads as before. To fix it, do one of these:

  • Pass the directory to loadSchema().
  • Rename the file to schema.json.
  • To load only the physical layer on purpose, pass the file’s content to loadSchemaFromJson().

The schema.json name now matches in any case. On macOS and Windows, loadSchema("<dir>/Schema.json") used to drop the sibling tenant-policy.md, so ask() didn’t require a tenantScope. It now loads the whole directory: if that schema has a tenant policy, pass a tenantScope to ask().

SQL Server: handle SQL that the read-only check now refuses

Section titled “SQL Server: handle SQL that the read-only check now refuses”

On SQL Server, validateSelectSql and ask({ dialect: "sqlserver" }) now refuse a second statement written without a ;, such as SELECT 1 SELECT 2 (#327). T-SQL doesn’t need a ; between statements, so this used to pass. SQL that passed before and now throws SqlValidationError:

  • SQL_FORBIDDEN_KEYWORD for an unquoted name that is one of the new denied words, such as begin, declare, if, while, print, throw, open or close. Quote the name ([open]).
  • SQL_MULTI_STATEMENT for a second SELECT that no UNION, EXCEPT or INTERSECT joins, a parenthesized query right after a complete value, a label:, or code outside strings and quoted names that isn’t plain, such as a non-breaking space or a bare $.
  • The same checks for a dialect id that is no built-in engine, because its SQL must pass every engine’s checks.

If you validate SQL you didn’t get from ask(), check it against these rules. Fix common errors and Safety boundaries describe the check.

Set modes.askdbMode in config to allow bounded_results

Section titled “Set modes.askdbMode in config to allow bounded_results”

@askdb/client now applies config modes.askdbMode, as the loosest mode a call gets (#279, ADR 0025). The CLI and the HTTP API follow the same rule:

  • A per-call bounded_results no longer loosens the config. askdb ask --mode bounded_results, a client call’s mode: "bounded_results" and an HTTP request’s mode: "bounded_results" now run schema_only, with no error, unless askdb.config.* sets modes: { askdbMode: "bounded_results" }. The SQL doesn’t change today, because bounded_results produces the same SQL as schema_only.
  • The askdb.run.start log line from the CLI and the HTTP API no longer carries mode. Read it from askdb.pipeline.mode, in the same log stream.
  • An unknown mode in a client call, or an invalid modes.askdbMode in a hand-built AskDbRuntimeConfig, now throws AskDbError.

Give an embedded HTTP server a config to load

Section titled “Give an embedded HTTP server a config to load”

createAskDbHttpServer() from @askdb/http-api now loads its config when you create it, unless your process already called bootstrapAskDbEnv() (#280):

  • With no config loaded yet, it loads .env and askdb.config.* from process.cwd(). It throws when it finds no askdb.config.* there, or can’t read an existing .env. Before, it started anyway and returned 500 internal_error on every POST /ask. If your config lives in another directory, call bootstrapAskDbEnv() first.
  • Omitted port and host options now fall back to httpApi.listen.port and httpApi.listen.host, then to 3000 and 127.0.0.1. If your config sets httpApi.listen, the server now listens there.

Config loading describes the rule.

@askdb/enrich saves no longer follow symbolic links (#372):

  • saveConcepts() throws Refusing to write concepts.md outside the schema directory: "concepts.md" is a symbolic link when concepts.md is one. Replace the link with a regular file in the schema directory. Studio’s tenant-policy save refuses a link at tenant-policy.md the same way.
  • Saving concepts.md or tenant-policy.md needs write access to the schema directory, because the save writes a temporary file there and renames it over the target. A writable file in a read-only directory now fails with EACCES.

Pass a loaded schema to buildSuggestionTarget() and buildSuggestionContext()

Section titled “Pass a loaded schema to buildSuggestionTarget() and buildSuggestionContext()”

@askdb/enrich’s buildSuggestionTarget() and buildSuggestionContext() now take loadSchema()’s result instead of a Workspace (#625, ADR 0026). Before, they read the tables straight from schema.json, so a column marked sensitive in tables/*.md, or through a sensitive table, wasn’t tagged sensitive in the suggestion prompt. Load the schema and pass it where you passed the workspace:

// Before
const target = buildSuggestionTarget(workspace, source);
const context = buildSuggestionContext(workspace, source.tableId);
// After
const schema = loadSchema(workspace.schemaDir); // from @askdb/core
const target = buildSuggestionTarget(schema, source);
const context = buildSuggestionContext(schema, source.tableId);

The context no longer lists an untracked table (tracked: false) as a neighbor, since ask()’s prompt leaves it out too. suggestEnrichment() describes the prompt these build.

  • ask() under a global scope throws TenantScopeError (UNRESOLVED_TENANT_PLACEHOLDER) when the SQL still holds a :tenant_* placeholder, instead of returning it unfilled. Admin SQL without a placeholder is unaffected.
  • A placeholder that isn’t a lowercase name, such as :Status, no longer resolves. bindPreparedQuery() throws QueryParameterError (UNRESOLVED_PLACEHOLDER), and ask() returns no unboundSql or preparedQuery for that SQL.
  • An unknown sensitiveGuardrailMode, such as "Strict" read from your own config, now refuses a sensitive reference, as "strict" does. Pass exactly "off", "warn" or "strict".
  • ask() logs askdb.pipeline.failed before it throws a tenant or sensitive guardrail error, as it already did for a read-only failure. If you alert on that event, expect more of them.
  • Omitting sensitive identifiers withholds more text (#374). With omitSensitiveIdentifiersFromNlToSqlPrompt: true, a description, alias, enum value, common query language block or retrieved chunk that names a sensitive column no longer reaches the model. If you build an NlToSqlSchemaFormatStats object by hand, such as a test double, set its new mentionDroppedCount field (0 keeps the old meaning).
  • Studio follows modes.omitSensitiveFromPrompt in the Playground and in Suggest (#601). With it on, the Playground’s prompt leaves out sensitive columns and text that names one, and Suggest on a sensitive column shows an error (400 from POST /api/suggest) instead of a draft. See Tables and enrichment.
  • The enrichment suggestion prompt has a new format. buildEnrichmentUserPrompt() lists the target table the way ask()’s prompt does (TABLE public.users, then lines such as - email text (NOT NULL) (sensitive)), so a test that compares the prompt text needs updating. suggestEnrichment() takes a new omitSensitiveIdentifiersFromPrompt option; pass your modes.omitSensitiveFromPrompt. With it on, a sensitive target column throws SensitiveEnrichmentTargetError.
  • Results gain verdict, which lists every finding of the SQL checks. tenantGuardrail and sensitiveGuardrail stay. See Guardrail verdict.

Released with @askdb/client 1.0.0-beta.7, @askdb/ai 0.1.0-beta.8, @askdb/config 1.0.0-beta.13, @askdb/rag 0.2.0-beta.24, @askdb/enrich 0.2.0-beta.15, @askdb/introspect 0.3.0-beta.18 and @askdb/studio 0.2.0-beta.37. Every other package got a new version too; its CHANGELOG lists what changed.

Every package’s engines.node is now >=22.23.4, up from >=22.12. better-sqlite3 13, which the @askdb/sqlite and @askdb/studio peer ranges allow, crashes on Node 22.12 and 22.13.

ai (the Vercel AI SDK) is now a peer dependency of @askdb/core, with the range ^6.0.0 || ^7.0.51, so your app owns its version. Add it to your own package.json:

Terminal window
npm install ai

npm and pnpm install a missing peer for you, but Yarn doesn’t. That includes installs that only introspect (@askdb/introspect, @askdb/prisma and the engine packages), because @askdb/core loads ai when it’s imported. @askdb/core accepts AI SDK 6 or 7. The config-driven path, @askdb/ai and @askdb/client, needs AI SDK 7.

Install the AI SDK package for your provider

Section titled “Install the AI SDK package for your provider”

The OpenAI, Azure OpenAI and Foundry, Google, and Anthropic providers are now built into @askdb/ai, with a new gateway provider for the Vercel AI Gateway. Each provider’s SDK is an optional peer dependency (^4.0.0), so install the one you configure:

Terminal window
npm install @askdb/ai @ai-sdk/openai

Without it, building the model fails with Provider 'openai' requires the optional peer dependency @ai-sdk/openai. Install it: npm i @ai-sdk/openai. The gateway provider needs no extra package. createAskDb({ config }) and createAiRegistry() with no arguments now register every built-in provider, so you can drop providers: [openaiProvider].

If you bundle your app with webpack 5, the build fails with Module not found for every @ai-sdk/* package you didn’t install. Install them, or list the ones you don’t use in externals, as Bundle an app that uses AskDB shows. esbuild needs no change.

Move model settings to ai.language and ai.embedding

Section titled “Move model settings to ai.language and ai.embedding”

The ai config now separates provider connections from model choice (ADR 0016). ai.providerConfig holds only connections: keys, endpoints and Azure resources. The language model moves to ai.language, and the embedding model behind rag.embedder: "ai" moves to ai.embedding:

// Before
ai: {
provider: "openai",
providerConfig: { openai: { apiKey: env("OPENAI_API_KEY"), model: "gpt-4o-mini" } },
reasoning: { effort: "low" },
},
rag: {
embedder: "openai",
embedderConfig: { openai: { model: "text-embedding-3-small" } },
store: "pgvector",
storeConfig: { pgvector: { databaseUrl: env("PGVECTOR_URL"), dimensions: 1536 } },
},
// After
ai: {
provider: "openai",
providerConfig: { openai: { apiKey: env("OPENAI_API_KEY") } },
language: { model: "gpt-4o-mini", reasoning: { effort: "low" } },
embedding: { model: "text-embedding-3-small", dimensions: 1536 },
},
rag: {
embedder: "ai",
store: "pgvector",
storeConfig: { pgvector: { databaseUrl: env("PGVECTOR_URL") } },
},

A config in the old shape still loaded in this release. Each old key was translated at load and reported once as a Node DeprecationWarning with code ASKDB_CONFIG_DEPRECATED, naming its new location, until beta.50 removed them. These old-shape configs failed to load, with an error that said what to change:

  • A rag.embedderConfig.openai.apiKey or .baseUrl whose embedding model comes from a provider other than openai, azure, foundry or gateway. Before, Studio sent that key to the language model’s provider.
  • rag.embedder: "ai-sdk" with no embedding model, on a provider other than openai, azure or foundry. Set ai.embedding.model.
  • rag.embedder: "ai-sdk" with Anthropic as the language model’s provider. Anthropic has no embeddings API: set ai.embedding.provider to another provider.
  • Two different widths in rag.embedderConfig.openai.dimension and rag.storeConfig.pgvector.dimensions.
  • An old key next to its replacement: ai.reasoning with ai.language.reasoning, ai.providerConfig.custom with ai.providerConfig.<provider id>, or rag.embedderConfig with ai.embedding.

With rag.embedder: "ai", ai.embedding.model is required: AskDB no longer picks an embedding model for you. Replace the removed config keys maps every old key to its replacement.

The rag block is now optional: without it, AskDB uses the mock embedder and the in-memory store. In TypeScript, AskDbConfig["rag"] is now optional, so code that reads config.rag or getAskDbRuntimeConfig().structured.rag needs a check. Read getAskDbRuntimeConfig().rag instead: its store and storeConfig are always set. A config that sets ai.embedding with no rag block fails to load.

The RAG command line moved into the askdb package as askdb rag. @askdb/rag’s askdb-rag binary now prints the askdb rag command to run and exits 1, until 1.0.0-beta.50 removes it.

BeforeAfter
askdb-rag index …askdb rag index … (also query and setup-store)
--api-key <key>Removed. Put the key on a connection in ai.providerConfig.
--embedder openai--embedder ai, with rag.embedder: "ai" and an ai.embedding section in your config
setup-store with no --dimensionssetup-store --dimensions <n>, your model’s vector width. It used to assume one.
No --store, which meant the file store--store file on every command, or rag.store: "file" in your config. askdb rag uses rag.store, which is "memory" when the config has no rag block, so index would build an index that ends with the process, and query would refuse it.

askdb rag reads askdb.config.*, and its flags still win. The schema directory falls back to introspection.outputDir, and the store flags fall back to rag.store and rag.storeConfig. See askdb rag for every flag.

The first index run after you upgrade re-embeds every chunk. Chunk ids are now scoped by schema (chunk:<schemaId>:<local-id>), the lock file is at version 2, and AI embedder ids are now ai-sdk:<provider>:<model>:<dimensions>, ending in default when no width is set.

  1. If you create the pgvector table from your own migrations and never call ensureSchema(), add the new column first. Use your table name if it isn’t the default:

    ALTER TABLE askdb_rag_chunks ADD COLUMN IF NOT EXISTS content_hash text;
  2. Run askdb rag index, or buildSchemaIndex() in your own code. It deletes that schema’s old-format chunk ids. Until you re-index, askdb rag query refuses an index that askdb-rag --embedder openai built.

  3. In Studio, rebuild an index that shows as stale. An index built without ai.embedding.dimensions shows as stale once.

Sensitive-column mentions are now matched across the whole schema (ADR 0017): a note in orders that says “match via users.ssn” is left out of the index by default. Some schemas exclude different chunks after upgrading, and the re-index picks that up. If you wrote your own vector store, implement hashesByPrefix, or describe() so the indexer can trust the lock file’s hashes: a store with neither re-embeds every chunk on each run. Implement idsBySchema too, so a re-index prunes chunks removed from the schema without touching other schemas. The @askdb/rag API describes both.

Rebuild schema bundles that have a tenant policy

Section titled “Rebuild schema bundles that have a tenant policy”

askdb bundle now puts tenant-policy.md in the bundle. Bundles built with askdb 1.0.0-beta.43 or earlier left it out, so ask() loaded them as single-tenant and didn’t require a tenantScope. Rebuild every bundle whose schema directory has a tenant-policy.md, then check that loadSchema(bundlePath).tenantPolicy is defined.

Turn off omitSensitiveFromPrompt in config to allow per-call false

Section titled “Turn off omitSensitiveFromPrompt in config to allow per-call false”

In @askdb/client, config modes.omitSensitiveFromPrompt: true is now a floor. A per-call omitSensitiveIdentifiersFromNlToSqlPrompt: false no longer turns it off. @askdb/http-api does the same: a request’s omitSensitiveFromPrompt: false can’t override the config. If some calls need sensitive names in the prompt, leave the config false and pass true on the calls that need omission.

Update HTTP API clients for the new status codes

Section titled “Update HTTP API clients for the new status codes”

POST /ask on @askdb/http-api changed:

  • A failed model call returns 502 sql_generation_error with a generic message. Before, it returned 400 with the provider’s own error text.
  • Unknown errors return 500 internal_error. 422 guardrail_violation is reserved for guardrail errors.
  • omitSensitiveFromPrompt and explain must be JSON booleans. A string or number, such as "false", returns 400 bad_request.
  • A request that sends schemaJson gets 403 schema_override_disabled, unless the server sets httpApi.allowSchemaOverride: true.
  • A model call that runs past httpApi.requestTimeoutMs (default 60000) returns 502.

See the HTTP API reference for every error code.

Remove the trailing ; before you wrap the SQL

Section titled “Remove the trailing ; before you wrap the SQL”

ask() now keeps the model’s single trailing ; in the SQL it returns (ADR 0018). unboundSql, preparedQuery.namedSql and a rebound query keep it too. If you wrap the SQL in another statement, remove the ; first, or the result is a syntax error on every engine:

const statement = sql.replace(/;$/, "").trimEnd();
const cappedSql = `SELECT * FROM (${statement}) AS askdb_q LIMIT 1000`;

This wrapper works on Postgres and SQLite only. Cap the rows a query returns shows a cap that works on every engine and keeps the query’s ORDER BY.

  • dotenv 18: bootstrapAskDbEnv no longer decrypts a .env.vault file when DOTENV_KEY is set. Load those values another way.
  • MySQL: a connection URL with no database name now throws, instead of introspecting an empty schema. A foreign key into a database you don’t introspect is now left out, with a cross_database_fk warning.
  • Postgres: foreign keys to and from partitions of a partitioned table are no longer rendered. If you introspect from an export, re-export the foreign_keys template (askdb introspect templates --engine postgres). Where foreign keys reference a partitioned table, the schema lock hash changes once.
  • Connector types: a connector adapter’s provider and ConnectorProvider are now open string types (ConnectorProviderId). A switch over them needs a default branch.
  • Built-in AI adapters: createLanguageModel and createEmbeddingModel are now async. Await them if you call an adapter directly.
  • @askdb/enrich table saves: saveTable() refuses to write when tables/ or the table’s markdown file is a symbolic link, or when the file name would land outside tables/. It also needs write access to tables/, because it saves through a temporary file there. Replace such links with regular files.
  • Bundles: bundleSchemaDirectory(), and so askdb bundle, fails when tenant-policy.md, concepts.md or tables/ exists but can’t be read, instead of leaving it out.
DeprecatedUse insteadRemoved in
@askdb/ai-openai, @askdb/ai-azure, @askdb/ai-google, @askdb/ai-anthropic@askdb/ai and @ai-sdk/<provider>1.0.0-beta.45
@askdb/connectors@askdb/introspect and @askdb/introspect/kit1.0.0-beta.45
The old ai and rag config keysai.language, ai.embedding and connections, as Replace the removed config keys maps1.0.0-beta.50
OpenaiConfig, AzureConfig and the other <Provider>Config typesOpenaiConnection, AzureConnection, …1.0.0-beta.50
OpenaiAiConfig and the other <Provider>AiConfig typesAskDbAiConfig1.0.0-beta.50
OpenaiRagEmbedderConfigai.embedding and a connection in ai.providerConfig1.0.0-beta.50
DEFAULT_OPENAI_CHAT_MODEL and the other DEFAULT_*_CHAT_MODEL exportsDEFAULT_*_LANGUAGE_MODEL1.0.0-beta.50
ProviderEnvSpec.defaultEmbeddingModel (its fallback warned with code ASKDB_AI_DEFAULT_EMBEDDING_MODEL)ai.embedding.model, or ASKDB_AI_EMBEDDING_MODEL in an env map, as Set an embedding model on @askdb/ai’s env path shows1.0.0-beta.50
askdb rag --embedder openai, --embedder ai-sdk--embedder ai1.0.0-beta.50
The askdb-rag binaryaskdb rag1.0.0-beta.50
createOpenAiEmbedder from @askdb/rag (deprecated in @askdb/rag 0.2.0-beta.17)createAiSdkEmbedder with an ai.embedding model or your own AI SDK model, as Replace createOpenAiEmbedder from @askdb/rag shows1.0.0-beta.51
BundledSchemaV2 from @askdb/enrichBundledSchemaV2 from @askdb/corenot yet set