Skip to content

feat(engine): narrow try()'s error to what a statement writes through - #89

Closed
colinhacks wants to merge 3 commits into
run-safefrom
run-safe-narrow
Closed

colinhacks wants to merge 3 commits into
run-safefrom
run-safe-narrow

Conversation

@colinhacks

Copy link
Copy Markdown
Owner

Builds on #88. When a query runs none of the user's own code, its QueryError is narrowed to the names each error can carry. The engine works this out from the catalog for each statement.

if (result.error?.condition === "unique_violation") {
  result.error.constraint; // "users_email_key" | "users_pkey" | undefined
}

A statement is closed when it runs only the database's own code, in the statement itself or in anything it sets off. Each closed statement's narrowing goes into the analysis as errors, beside the freshness facts and in the same round. A statement that isn't closed keeps the whole type, since a trigger can raise any code with any name.

Postgres

One catalog query per statement. It reports anything that opens the statement:

  • a trigger or rule on a table a write reaches. The tables a write reaches are its partitions, its inheritance children, a view's base tables, and the tables its foreign keys' actions write when a key can change;
  • a foreign table;
  • a default, check, index expression, partition key, domain check, policy or view that depends on a user function or operator (pg_depend);
  • a column or cast type whose input or output a user wrote;
  • a user function or operator on built-in types with a name the statement uses;
  • a user cast between built-in types;
  • a server newer than 18.

Otherwise code is the strict list, and each error names only what the statement writes through:

  • unique violations: the index names;
  • exclusion violations: the constraint names;
  • not-null violations: the columns and domains;
  • foreign key violations: the child side, plus the parent side when a key can change;
  • restrict violations;
  • check violations: table and domain checks.

MySQL

information_schema reports:

  • a trigger on a table a write reaches;
  • a stored function the statement calls;
  • a view that uses a routine.

Otherwise each error names only what the statement writes through: duplicate keys as table.index, foreign keys on either side, checks, NOT NULL columns, and the columns of value errors.

SQLite

Pragmas report a trigger on a table a write reaches. Otherwise each error names only what the statement writes through: unique keys' columns as SQLite's message lists them, expression indexes, primary-key, rowid, NOT NULL and STRICT columns. Each name is listed under the primary SQLITE_CONSTRAINT too, for drivers that give only that. SQLite's catalog doesn't name a CHECK, so CHECK errors aren't narrowed.

Types and checks

  • PostgresQueryError<N>, MysqlQueryError<N> and SqliteQueryError<N> each take the narrowing. A module declares Query<…, QueryError>.
  • The oracle checks every case's runs: each error raised must fit its statement's narrowing. Its three new cases (errors, mysql_errors, sqlite_errors) carry an errors.txt. All 119 cases pass with --strict-coverage.
  • New tests: the parse's operators, types and key changes; declaration tests for a narrowed and a trigger-opened module on Postgres and SQLite; type tests for each dialect's narrowed members.
  • All three engine.wasm are rebuilt.

Copilot AI balanced review requested due to automatic review settings October 23, 2026 21:21
@vercel

vercel Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
reylo Ready Ready Preview Oct 23, 2026 9:59pm UTC

Request Review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Several paths mark a statement closed even though it can run user code, or narrow a field the run time fills with a name outside the list. When that happens the emitted QueryError type is wrong, and .try()'s narrowed type is only useful if it is sound.

Reviewed changes

Initial review of the single commit, which narrows .try()'s error type per statement on all three dialects.

  • Core errors round: Database::errors runs beside fresh::facts in with_facts, and the result is serialized as Analysis.errors.
  • Postgres closure query: one catalog query covers triggers, rules, foreign tables, pg_depend on user code, user-I/O types, same-named user functions, operators and casts, and lists the constraint, table, column and domain names.
  • MySQL closure query: information_schema covers triggers, stored functions called by name, and views that use routines, and lists keys, foreign keys, checks and columns.
  • SQLite closure query: pragmas cover triggers and list unique keys, rowid, NOT NULL and STRICT columns, each mirrored under the primary SQLITE_CONSTRAINT.
  • Types: PostgresQueryError<N>, MysqlQueryError<N> and SqliteQueryError<N>, plus queryErrorType, which writes the module's QueryError.
  • Oracle: every raised error is checked against its statement's narrowing, and three new cases carry errors.txt.
  • Docs and skill: each dialect page, 02-usage.mdx and SKILL.md describe the narrowing.

⚠️ The .reylo/ cache can keep a stale narrowing after a migration

Analyses are reused while the schema fingerprint matches (src/analyze.ts:399-424). The errors field now depends on catalog objects that the fingerprint does not hash. On MySQL, create trigger audited_audit before insert on audited … does not change the fingerprint. The cached insert_audited.sql entry therefore keeps its closed narrowing until something unrelated changes the schema. The same happens when a CHECK or a foreign key is added without a new index.

Technical details
# Schema fingerprint misses what `errors` reads

## Affected sites
- src/analyze.ts:416-421 — MySQL FINGERPRINT has columns, statistics, views, routines only: no `information_schema.triggers`, `referential_constraints`, `table_constraints`/`check_constraints`
- src/analyze.ts:400-415 — Postgres FINGERPRINT has no `pg_attrdef` (a new `DEFAULT user_fn()` on an existing column), no `pg_policy`, no `pg_cast` — all of which ERRORS_QUERY reads to open a statement

## Required outcome
- Any catalog change that would flip a statement between open and closed, or change the names it lists, changes the fingerprint, so the cached analysis is not reused.

## Suggested approach
- MySQL: add `union all` rows for triggers (name, event table, timing, event), referential constraints (name, table, referenced table, rules) and check constraints (name, clause).
- Postgres: add rows for `pg_attrdef` (`adrelid`, `adnum`, md5 of `pg_get_expr`), `pg_policy`, and `pg_cast` where `oid >= 16384`.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

Comment thread engine/postgres/src/errors.rs Resolved
Comment thread engine/mysql/src/errors.rs Resolved
Comment thread engine/mysql/src/errors.rs Resolved
Comment thread engine/sqlite/src/errors.rs Resolved
Comment thread engine/postgres/src/analyze.rs Resolved
A user function opens a statement whatever it takes, and a user operator or cast where the statement can reach its types. MySQL lists every table a multi-table write may reach, reads quoted and commented calls, and looks a stored function up in any schema. SQLite's primary code no longer narrows the constraint, which may be a CHECK's.
@colinhacks

Copy link
Copy Markdown
Owner Author

On the review's fingerprint finding: fixed in bce966d. The schema fingerprint in src/analyze.ts now hashes what the error narrowing reads. On Postgres that is defaults and generated columns (pg_attrdef), policies and user casts. On MySQL it is triggers, referential constraints, table constraints and the stored functions of every other schema. A migration that adds a trigger, a CHECK or a foreign key now invalidates the cached analysis.

CI's test.yml runs only on pull requests into main, so it doesn't run here. On bce966d I ran its steps locally, with Postgres 18 and MySQL 8.4: nub run typecheck; nub run test (237 passed, 0 skipped); cargo build --workspace --all-targets (no warnings); the engine and oracle tests; the whole oracle with --strict-coverage (119 cases, 0 failed); engine/build.sh; and the site build.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The new present set misses one case. A user operator can still be chosen for an argument of unknown type, a parameter or a literal, and the statement is then typed closed.

Reviewed changes

Reviewed the two commits since the last review, which address its five threads and the cache concern.

  • Postgres closing rules: any user function the statement calls by name now opens it. A user operator or cast opens it only when every user type it takes is in a new recursive present set.
  • MySQL write set: mysql_params::tables now lists every table of a multi-table UPDATE or DELETE as written. A non-query that names nothing it writes opens, and stored functions are matched in every schema.
  • Word scan: called now reads quoted identifiers, looks past comments for the (, and reads inside MySQL's /*! */ comments.
  • SQLite primary code: constraint is no longer copied to SQLITE_CONSTRAINT.
  • Schema fingerprint: Postgres now hashes defaults, policies and user casts. MySQL now hashes triggers, foreign keys, constraints and other schemas' functions.
  • Oracle cases: new statements cover a function and an operator on an enum, quoted and commented calls, and multi-table DELETEs with and without a trigger.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

WHERE a.attnum > 0 AND NOT a.attisdropped AND t.oid IN (SELECT oid FROM coded) \
UNION ALL SELECT 'open', 'function', p.proname::text, NULL FROM pg_proc p WHERE p.oid >= 16384 AND p.proname = ANY($3::text[]) \
UNION ALL SELECT 'open', 'operator', o.oprname::text, NULL FROM pg_operator o WHERE o.oid >= 16384 AND o.oprname = ANY($4::text[]) \
AND (o.oprleft < 16384 OR o.oprleft IN (SELECT oid FROM present)) AND (o.oprright < 16384 OR o.oprright IN (SELECT oid FROM present)) \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reasoning used for functions also applies to operators: an argument of unknown type takes the type of the only candidate. With the case's ~~~ (mood, int), select :m ~~~ 1 as x reads no table and names no type, so mood is not in present and the statement is closed. Yet $1 resolves to mood, and the run raises U0003. I checked this on a live server: prepare p as select $1 ~~~ 1 gives parameter_types = {mood}, and execute p('sad') raises. select 'sad' ~~~ 1 behaves the same way.

Technical details
# A user operator chosen for an unknown-typed argument is unseen

## Affected sites
- engine/postgres/src/errors.rs:90-91 — the operator row requires each user arg type to be in `present`, but an unknown literal or untyped parameter can resolve to a user type that is not in `present`
- engine/postgres/src/errors.rs:93-94 — casts are probably fine, since a cast names its target type, but check them against the same rule

## Required outcome
- A statement opens when a user operator may be chosen for an argument of unknown type, at least when no server operator (oid < 16384) has that name.
- Add an oracle statement to `oracle/cases/errors/`, e.g. `select :m ~~~ 1 as x`, expected `open`.

## Suggested approach
- Also open a user operator whose `oprname` no built-in operator shares, whatever its argument types. This keeps citext's `=` from opening every statement.
- Alternatively, add the described parameter types to `present`. That still misses a literal such as `'sad' ~~~ 1`.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1b85dc5, now on #96 (#96). A user operator also opens the statement when it is a prefix operator, or when the server has no operator of the name taking the other argument's type on both sides (text when neither type is present), which Postgres would pick first. New oracle statements compare_param.sql (select :m ~~~ 1) and compare_literal.sql (select 'sad' ~~~ 1) are both open.

@colinhacks

Copy link
Copy Markdown
Owner Author

This continues in #96, rebased onto main: this pull request closed when its base branch was deleted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants