Skip to content

Log errors from watched queries - #1068

Open
giaBaoJS wants to merge 3 commits into
powersync-ja:mainfrom
giaBaoJS:log-watched-query-errors
Open

Log errors from watched queries#1068
giaBaoJS wants to merge 3 commits into
powersync-ja:mainfrom
giaBaoJS:log-watched-query-errors

Conversation

@giaBaoJS

@giaBaoJS giaBaoJS commented Aug 14, 2026

Copy link
Copy Markdown

Overview

useQuery gives no signal at all when the underlying query fails. The error is stored on the returned error field and dispatched to onError listeners, but nothing is inspected by default, so an invalid query looks like a query that simply never returns rows — the console stays empty, as reported in #834.

The deprecated callback API does not have this problem: watchWithCallback defaults onError to a logger call, so the WatchedQuery/useQuery path lost that behaviour rather than never having it.

Changes

Errors are now logged with the database's logger, matching the existing 'Watched query error handler threw an Error' log in the same file.

  • AbstractQueryProcessor.updateState logs whenever an error is set on the state. This is the single funnel every watched-query failure passes through, which matters because the two paths are separate:

    • table resolution failing in linkQuery (caught by runWithReporting) — this is what a missing table hits, since resolveTables runs EXPLAIN <sql>;
    • the query itself failing on re-execution (caught inside the onChange callbacks of OnChangeQueryProcessor and DifferentialQueryProcessor) — this is where runtime failures such as SQLite I/O errors surface.

    error: null is used to clear a previous error, so only truthy errors are logged.

  • The log is skipped when a listener registered an onError handler. Such a listener already reports the error itself, so logging would only duplicate its output — this is the opt-out for watched queries.

  • watchWithCallback no longer defaults onError to anything and only registers the listener handler the caller supplied. Callers that pass an onError therefore keep their pre-existing behaviour of being the only error consumer, and callers that do not now get a log where they previously got the API's own default log.

  • useSingleQuery logs the same way for the runQueryOnce path, which does not go through a query processor. compiledQuery is declared outside the try block (but still only assigned inside it, so a throwing compile() leaves it undefined), which lets the log include the generated SQL when execute is what failed.

On log noise

Queries that are expected to fail will now produce output where they previously produced none, unless the caller handles the error itself. That seemed like the right trade-off given the issue: the error is only emitted once per failure, at error level, and applications that want to handle failures themselves can install an onError handler, supply a logger with a higher minLevel, or provide their own logger implementation when constructing the database.

Tests

packages/react/tests/useQuery.test.tsx (run in both normal and StrictMode), one test per failure path: table resolution, query execution, runQueryOnce, and runQueryOnce with a failing execute asserting the generated SQL is in the message. Each asserts that the logged record carries the real underlying error, and that it reaches console.error through the default logger — the console being empty is what the issue actually describes.

packages/web/tests/watch.test.ts covers the opt-out on the watchWithCallback API: the same failing watch logs when no onError is supplied and does not log when one is.

Fixes #834.

Errors raised while resolving or executing a watched query were only
reported on the query state and to error listeners. Neither is inspected
by default, so `useQuery` appeared to silently do nothing when the query
was invalid.

Log the error with the database's logger from `AbstractQueryProcessor`,
which covers both the table resolution and query execution paths, and do
the same for the `runQueryOnce` path in `useSingleQuery`.

The default `onError` handler of `watchWithCallback` logged the error
itself. That is now handled by the watched query, so the default handler
no longer logs to avoid emitting the same error twice.
@changeset-bot

changeset-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: afdfbc5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@powersync/shared-internals Patch
@powersync/react Patch
@powersync/adapter-sql-js Patch
@powersync/capacitor Patch
@powersync/node Patch
@powersync/react-native Patch
@powersync/web Patch
@powersync/diagnostics-app Patch
@powersync/tanstack-react-query Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

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

I like the idea of logging errors by default. I still think it should be possible to opt-out of that to reduce noise in cases where we know the user has their own error hooks. That's impossible for hooks, but maybe we can improve that for watched queries.

}

if (typeof update.error !== 'undefined') {
if (update.error) {

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.

To avoid duplicate logs when users install their own error handlers, could we skip the log if an onError handler exists through iterateAsyncListenersWithError? That means we'd make the onError parameter nullable in watchWithCallback then.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in afdfbc5. AbstractQueryProcessor now skips the log when listenerCounts[WatchedQueryListenerEvent.ON_ERROR] > 0, i.e. when at least one of the listeners iterateAsyncListenersWithError dispatches to handles the error itself.

That also resolved the watchWithCallback default in the other direction than this PR originally had it. onError is now left undefined when the caller supplies no handler, and the listener registered on the watched query is the caller's handler rather than a wrapper — otherwise the presence check would always be true for that API and it could never log. So callers that pass an onError keep exactly their pre-existing behaviour (their handler is the only consumer, nothing is logged), and callers that pass none get the watched query's log instead of the old default handler's 'Error in watch' log. The only user-visible difference for the callback API is the message text.

Covered by two new tests in packages/web/tests/watch.test.ts: the same failing watch logs without an onError and does not log with one.

error: undefined
}));
} catch (error) {
// Matches the logging done by watched queries, so that `runQueryOnce` failures are just as

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.

Can we move the compiledQuery variable out of the try block (but still only assign it in there in case compile() throws)? That way, we could include the generated SQL text for queries if execute fails.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in afdfbc5. compiledQuery is declared before the try and still only assigned inside it, so it stays undefined if compile() is what threw and the message falls back to the plain 'Error in watched query'. When it is set, the message becomes Error in watched query: <sql>.

New test in packages/react/tests/useQuery.test.tsx uses a query whose compile() succeeds and whose execute() rejects, and asserts the SQL text is in both the log record and the console.error output.

Review feedback on powersync-ja#1068.

The processor now skips its log when a listener registered an `onError`
handler, since such a listener already reports the error itself. That
makes `watchWithCallback`'s default `onError` unnecessary: it is left
undefined when the caller does not supply one, restoring the original
behaviour of that API for callers that do handle errors, while callers
that do not still get a log.

`useSingleQuery` hoists `compiledQuery` out of the try block (still only
assigned inside it, so a throwing `compile()` leaves it undefined) so the
generated SQL can be included in the log when `execute` fails.
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.

useQuery swallows underlying Kysely errors

2 participants