Skip to content

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

Merged
colinhacks merged 15 commits into
mainfrom
run-safe-narrow
Oct 24, 2026
Merged

colinhacks merged 15 commits into
mainfrom
run-safe-narrow

Conversation

@colinhacks

Copy link
Copy Markdown
Owner

Builds on #88, now merged. This replaces #89, which closed when #88's branch was deleted; its review is there. 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. The tables and columns are listed under the primary SQLITE_CONSTRAINT too, for drivers that give only that; a constraint isn't, since under that code it may be a CHECK's. 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 120 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.

Fixes from #89's review

  • Postgres functions: a user's function now opens the statement whatever types it takes. An untyped literal or parameter takes the type of the only function of its name.
  • Postgres operators and casts: a user's operator or cast opens it wherever the statement can reach the operator's or cast's types. Those types are columns' types, row types, the types the statement names, what the functions it calls return, preferred types, implicit cast targets, and the parts of each.
  • Postgres untyped operands: a user's operator also opens it when it is a prefix operator, or when no built-in operator of its name takes the other argument's type. That covers select :m ~~~ 1, where Postgres resolves the parameter to a user type.
  • MySQL: every table a multi-table write may reach is listed, calls written quoted or commented are read, and a stored function is looked up in any schema.
  • SQLite: the primary SQLITE_CONSTRAINT code no longer narrows constraint, which may be a CHECK's.
  • Analysis cache: the key now hashes what the narrowing reads.

Copilot AI balanced review requested due to automatic review settings October 23, 2026 22:41
@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 24, 2026 12:54am 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

Three ways a statement that runs user code still comes out closed, so its narrowed QueryError doesn't hold at run time. Each one is reproduced below: the two Postgres cases on a Postgres 16 cluster using this PR's ERRORS_QUERY, and the SQLite case with node:sqlite. On Postgres the narrowed type makes code the strict PostgresCode list, so a user's U0001 is a value the type says can't happen.

Reviewed changes

I reviewed the whole PR: the engine's error narrowing on all three dialects, the run time's narrowed error types, the cache fingerprint, the oracle's new check, and the docs.

  • Core — Errors and the word scanner in engine/core/src/errors.rs. with_facts reads D::errors in the same round as the freshness facts, and only when the analysis has no diagnostics.
  • Postgres — one catalog query reports what opens a statement (triggers, rules, foreign tables, pg_depend on user procs and operators, coded types, user functions, operators and casts) and the names each constraint error can carry. analyze.rs now collects operators, type names and changes_keys.
  • MySQL — an information_schema query covers triggers, stored functions in any schema and views that call routines. Multi-table UPDATE and DELETE now list every table they may write.
  • SQLite — a pragma-based query covers triggers, unique, primary-key, rowid, NOT NULL and STRICT names. These are copied to the primary SQLITE_CONSTRAINT code, except constraint.
  • Run time — PostgresQueryError<N>, MysqlQueryError<N> and SqliteQueryError<N>, plus queryErrorType. Modules now declare Query<…, QueryError>. The fingerprint also hashes what the narrowing reads.
  • Oracle — every raised error is checked against its statement's narrowing. Three new cases have an errors.txt.

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

Comment thread engine/postgres/src/errors.rs Resolved
Comment thread engine/postgres/src/errors.rs Resolved
Comment thread engine/sqlite/src/errors.rs Resolved

@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.

ℹ️ All three earlier findings are fixed, and I've resolved their threads. One related gap is left, with a suggestion inline.

Reviewed changes

This run covers the two commits since the last Pullfrog review: 91f7d00 and 0340c01.

  • Postgres domain defaults and operator classes: depends on now covers a domain's own dependencies (classid = 'pg_type' over dom) and dependencies on pg_opclass and pg_opfamily. Two new oracle statements, insert_defaulted and insert_ordered, cover them.
  • Postgres built-ins that run SQL text: a call to anything in RUNS_SQL, ts_stat or ts_rewrite now opens the statement before the catalog query runs. The new query_to_xml statement covers this.
  • SQLite name case: the written-table lookup, the foreign key lookup and the trigger lookup now compare with collate nocase. The new insert_member and delete_team statements cover this.
  • Fingerprint: the t row now hashes typdefault, and a new q row hashes user operator classes.
  • Docs: postgres.mdx now lists operator classes and SQL-running functions as things that keep the error type unnarrowed.

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

Comment thread engine/postgres/src/errors.rs Resolved

@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.

ℹ️ The fix covers a direct read through a user operator class, and I've resolved that thread. One related gap remains, for inherited and partitioned children, and it has a suggestion inline.

Reviewed changes

This run covers the one commit since the last Pullfrog review, 822d6f1.

  • Operator classes on reads: a new ERRORS_QUERY clause opens a statement when any relation it reads, or any index on one, depends on a user's pg_opclass or pg_opfamily. The same clause covers partition keys. The doc comment now says so, and the new oracle statement select_ordered.sql blesses as open.

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

Comment thread engine/postgres/src/errors.rs Resolved
@colinhacks
colinhacks force-pushed the run-safe-narrow branch 2 times, most recently from d22a65d to 897b1eb Compare October 24, 2026 00:14

@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.

ℹ️ No new correctness issues. There's one misplaced doc comment, noted inline. The thread on reads through inherited and partitioned children is still open, since the code it points at hasn't changed.

Reviewed changes

This run covers what changed since the last Pullfrog review (822d6f1). The branch was rebased onto main at ec01766. The one new commit with code in it is ed7752b; the rest are engine and capture rebuilds.

  • Same request every round (Postgres): request now sorts and dedupes each of ERRORS_QUERY's list parameters, so the need is identical in every round. prefetch asks for it right after the rewrite, in the statement's first round. The new test the_query_is_asked_alike_every_round covers this.
  • Same request every round (MySQL): request builds the query text from the statement's kind and mysql_params::tables. prefetch asks for it next to the parameter prefetch. The inline kind match is now a function.
  • Rebuilds: all three engine.wasm files were rebuilt on the new main. The capture from main's new my_null_subqueries.sql now declares QueryError.

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

Comment thread engine/mysql/src/mysql.rs Resolved

@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.

✅ No new issues found. Both open threads are fixed, and I've resolved them.

Reviewed changes

This run covers the one commit since the last Pullfrog review, 6b9b13c.

  • Reads reach a parent's children (Postgres): edge now has a read flag. It is set on inheritance and partition edges and on view edges, and r follows only those, so foreign-key action edges stay write-only. A read through a parent now sees its children's user operator class indexes, sub-partition keys and foreign partitions. This is more conservative for ONLY reads, but still sound.
  • Oracle: the new select_logs.sql (an inheritance child with a thirteen_ops index) and select_parts.sql (a partition with one) both bless as open.
  • MySQL doc placement: fn kind now sits above parse's doc comment, so the doc attaches to parse again.
  • Rebuilds: the Postgres and MySQL engine.wasm files were rebuilt.

Pullfrog  | View workflow run | Using claude-opus-5-5 | 𝕏

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.
On Postgres, a domain's default and an index's operator class that call a user's code open the statement, as does a server function that runs SQL it is given, such as query_to_xml(). On SQLite, a trigger or a foreign key that spells its table in another case is found.
@pullfrog

pullfrog Bot commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

No configured credential can serve this run. Your Claude Pro/Max subscription has hit its usage limit, which resets at 2026-10-26 08:00 UTC. Re-trigger after it resets, or add an API key for this provider, which Pullfrog falls back to automatically.

The providers' responses
CLAUDE_CODE_OAUTH_TOKEN: 429: This request would exceed your account's rate limit. Please try again later.

Pick another model →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using claude-opus-5-5

@pullfrog

pullfrog Bot commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

No configured credential can serve this run. Your Claude Pro/Max subscription has hit its usage limit, which resets at 2026-10-26 08:00 UTC. Re-trigger after it resets, or add an API key for this provider, which Pullfrog falls back to automatically.

The providers' responses
CLAUDE_CODE_OAUTH_TOKEN: 429: This request would exceed your account's rate limit. Please try again later.

Pick another model →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using claude-opus-5-5

@colinhacks
colinhacks merged commit 963d65b into main Oct 24, 2026
10 of 11 checks passed
@colinhacks
colinhacks deleted the run-safe-narrow branch October 24, 2026 00:57
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