Skip to content

feat: watched-query plugin API (seed and observe hooks for watched queries) - #1101

Open
gartz wants to merge 14 commits into
powersync-ja:mainfrom
gartz:persistent-query-cache
Open

gartz wants to merge 14 commits into
powersync-ja:mainfrom
gartz:persistent-query-cache

Conversation

@gartz

@gartz gartz commented Sep 14, 2026

Copy link
Copy Markdown

What this proposes, in plain terms

What it does: Adds a small, opt-in extension point ("plugins") to the SDK's watched queries. A plugin can (a) show previously known data instantly while the real query is still starting up, and (b) observe query activity for logging/metrics. The query state now says where its data came from ('cache', 'live', ...), so apps can render honestly. With no plugins configured, nothing changes — existing apps are unaffected.

Why: On large local databases, the first render after a page load waits for the database to open and the first query to run — and that wait grows with database size (measured: ~1.3 s at 10 MB up to ~23 s at 200 MB). Users pressing refresh or back expect the previous screen instantly. Solving that needs a small amount of core cooperation, but the feature itself (caching policy, storage, encryption, TTLs) is opinionated and doesn't belong in the SDK. This PR adds only the generic hook points; features ship as external packages.

Who uses it — two working packages built against this API:

Package What it does Result
powersync-query-cache Persists last query results (memory + IndexedDB, optional encryption); repaints them on startup before the database opens Cold-boot first render: flat ~65–90 ms instead of 1.3–23 s; back/forward navigation repaints in <0.5 ms
powersync-query-logger Logs query lifecycle, time-to-first-result, row counts, slow-query warnings; structured telemetry sink Observability with zero app-code changes; also demonstrates the API is not cache-specific

Risk profile: All new API is marked @alpha. Plugin callbacks are sandboxed — a misbehaving plugin is detached and logged, and can never break a query. The full plugin path is exercised by new unit suites plus an end-to-end suite in the cache repo running against @powersync/web in a real browser.


Summary

This PR adds an opt-in watched-query plugin API to the SDK: a small contract that lets external packages observe watched queries and seed their initial state, without the SDK taking on any specific feature. No behavior changes when no plugins are configured.

const db = new PowerSyncDatabase({
  schema,
  database: { dbFilename: 'app.db' },
  plugins: [new QueryCachePlugin({ storage: new IndexedDbQueryCacheStorage() })]
});

const watched = db.query({ sql: 'SELECT * FROM todos' }).watch();
watched.state.source;     // 'placeholder' | 'cache' | 'live' — provenance of state.data
watched.state.sourceMeta; // plugin-defined detail (e.g. cachedAt) while seeded

Motivation

On large local databases, time-to-first-render is dominated by opening SQLite and running the first query — it grows with database size, while users expect the previous screen back instantly on refresh or back-navigation. A cache that paints the last known result before the database opens needs core cooperation (a seeding path into watched-query state, provenance so UIs can tell cached from live, lifecycle signals to know when to invalidate) — but the cache itself, with all its policy choices (storage, TTL, budgets, encryption), doesn't belong in the SDK. A plugin API keeps the core generic and lets features like this live outside.

Two working consumers were built against this API:

  • powersync-query-cache — persistent two-layer (memory → IndexedDB) query-result cache: paints the previous result on cold boot before SQLite opens, then swaps to live. Measured on 10–200 MB databases: cold-boot first rows go from 1.3–23 s (scales with size) to a flat ~65–90 ms, and in-session re-mounts (back/forward navigation) paint synchronously (<0.5 ms) from the memory layer. Benchmark suite and numbers are in the repo README.
  • powersync-query-logger — observational logging/telemetry: lifecycle, time-to-first-result, row counts, slow-query warnings, structured event sink. Exists partly to prove the API isn't cache-shaped.

API surface (all @alpha)

In @powersync/common:

  • WatchedQueryPluginid, optional onDatabaseOpen(ctx) (once ready; may return a disposer run on close), optional onWatchedQueryCreate(ctx) returning per-query hooks.
  • WatchedQueryHooksseedInitial() (synchronous, consulted while the initial state is built), onLink(seed, signal) (async seeding with a core-owned guard), onResult(rows, info) (observes live emissions), onDispose().
  • SeededResult{ data, source, sourceMeta }.
  • WatchedQueryState gains source: string and sourceMeta: unknown.
  • Query options gain extensions: Record<string, unknown> — per-query, per-plugin options (e.g. extensions: { cache: false }); definition-level extensions merge with per-call ones.
  • Database options gain plugins: WatchedQueryPlugin[]; the database emits a cleared event from disconnectAndClear().

Core semantics

  • Seed guard: a seed is rejected once a live result has arrived, after disposal, or after a settings change; the first adopted seed wins. Seeded data participates in differential diffing as the previous emission, so the live swap doesn't re-churn unchanged rows.
  • Provenance: any live assignment resets source to 'live' and clears sourceMeta. A seeded state reports isLoading: false (there is data to render) but keeps isFetching: true.
  • Fail-safe: every plugin callback is wrapped — a throwing plugin is logged and detached; it can never break a query or another plugin.
  • Isolation: a plugin sees only its own extensions[plugin.id] entry; no plugin-to-plugin communication.
  • Readiness: onDatabaseOpen runs before any query hooks; hooks for queries created pre-ready are deferred and created after open.
  • Hook lifetime = link lifetime: settings changes dispose and re-create hooks with a fresh abort signal.

Changes by package

  • @powersync/common — contract types, provenance fields, extensions, plugins option, cleared event (minor).
  • @powersync/shared-internals — plugin registry, query-signature helper, hook dispatch in the query processors (minor).
  • @powersync/react, @powersync/vue — hooks/composables surface source/sourceMeta and thread extensions (patch).
  • @powersync/web — README section documenting the plugin option (patch).
  • Changesets included.

Testing

  • New suites in packages/shared-internals/tests/client/plugins/: contract/registry/signature units, hook-dispatch semantics (seed guards, provenance transitions, settings-change rebinding, fail-safe detachment, extension isolation), differential seeding, and a telemetry example plugin that locks the API's second-consumer shape. shared-internals 98/98, common 29/29, pnpm build:packages clean.
  • End-to-end (in the cache plugin repo, against @powersync/web in headless Chromium): cold-boot cache paint before the live query resolves, disconnectAndClear() wiping, per-query opt-out.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Sep 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 34d5fef

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

This PR includes changesets to release 12 packages
Name Type
@powersync/react Minor
@powersync/vue Minor
@powersync/common Minor
@powersync/shared-internals Minor
@powersync/web Patch
@powersync/react-native Patch
@powersync/tanstack-react-query Patch
@powersync/diagnostics-app Patch
@powersync/nuxt Patch
@powersync/node Patch
@powersync/adapter-sql-js Patch
@powersync/capacitor 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

Copy link
Copy Markdown
Contributor

Thanks for the contribution! We believe this approach is not the right one to adopt for the JS SDK, but this sparked a discussion internally and there are definitely some areas we want to improve here (and would be happy to receive contributions for).

My main concern with this approach is complexity and code size: The watched query system is already pretty complex, so adding a plugin API on the existing system kind of makes things worse. Also, being instance members on existing classes, these can't be tree-shaken away and every user pays for them.

In our mind, the two plugins you've shared are probably the most relevant use cases, to the point where they should likely be accessible from the SDK in some way. It's a bit hard to come up with additional plugin use cases (maybe something like automatic retrying on errors?), so it's not clear that the plugin idea generalizes.

The inversion of control from a plugin approach (where the SDK drives the query lifecycle and invokes your hooks in response) probably makes things more complicated than they need to be. You can drive your own (arbitrarily complex) query workflows by listening for onChange and running queries in response to update notifications. This gives you a standard interface (AsyncIterable) that you can attach your own combinators to for logs, caching, retries or anything else.

It's a shame that the JS ecosystem doesn't really have standard transformations for this primitive (unlike say Streams in Dart or Flows in Kotlin), but I still feel like this is a more flexible approach to composing queries compared to a plugin system. Maybe some of these transformations should be exposed directly from the JS package as a starting point.

Caching

IMHO, putting a cache in front of a local SQLite database is absurd. If we're slow enough to make that seem like an option, we must look into ways to improve this. We have recently added support for opening multiple workers in parallel for the OPFS WriteAhead file system for example, which can reduce the time to the first read query.

To improve query performance, we have also made raw tables easier to use and have plans to make them nearly trivial to adopt.

Still, this is something that we haven't optimized for enough. SQLite is supposed to be fast enough for the complexity of a separate caching layer to make no sense, and if the JS SDK is too slow to use it directly, that's something we need to fix.

Logging and instrumention

Logs query lifecycle, time-to-first-result, row counts, slow-query warnings; structured telemetry sink

I think this is a great feature to have. We kind of implement this via the debugMode option when opening databases, but it's not nearly as detailed as the things you have.

I think watch() methods are the wrong level of abstraction for this, though. The underlying database driver could expose way more information about queries (on the web where we control the file system, we could e.g. print the amount of database pages read or written while a query was running). So doing this on a DBAdapter level is probably better, and performance-sensitive adapters like the wa-sqlite one for the web should likely emit more diagnostics results via the builtin option.

We have incrementally made DBAdapter easier to extend, and at this point writing a DBAdapter wrapping an existing one to instrument it with performance timings should be pretty straightforward.

powersync-js and others added 14 commits September 19, 2026 18:03
…enance types [watched-query-plugin-api]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stry [watched-query-plugin-api]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tate [watched-query-plugin-api]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ore-owned seed guards [watched-query-plugin-api]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… first-seed-wins [watched-query-plugin-api]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… in differential watches [watched-query-plugin-api]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ferential ticks [watched-query-plugin-api]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tabases [watched-query-plugin-api]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…atched-query-plugin-api]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… and Vue bindings [watched-query-plugin-api]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… [watched-query-plugin-api]

Final-review fixes to the watched-query plugin core.

Seeding correctness:
- OnChangeQueryProcessor never transitioned off a seed when a user comparator
  reported the live result equal to the seeded one — no data assignment meant no
  live result, no onResult, and a query stranded on source 'cache' forever. The
  gate now mirrors the differential processor's.
- constructInitialState did not arm the adopted-seed guard, so a slower async
  seed overwrote the newer synchronous one.
- The re-link path reset the seeding guards unconditionally, so the schemaChanged
  self-re-link (same settings object) repainted stale cached rows over live data.
  Guards now reopen only when the query signature actually changes; hook disposal,
  recreation and onLink rebinding with a fresh signal stay unconditional.
- The seed branch hardcoded isFetching: true, stranding it true for consumers that
  set reportFetching: false.
- A throwing user comparator escaped through onSeededDataAdopted into the plugin's
  seed() call and left the guard armed with nothing painted.
- init() created hooks after awaiting waitForReady() without re-checking closure,
  so a query closed while waiting got hooks that never received onDispose.

Contract surface:
- WatchedQueryPluginContext no longer exposes every plugin's options to every
  plugin; the merged record is passed to registry.createHooks() separately and
  each plugin still sees only its own extensionOptions.
- Definition-level extensions are re-merged on every settings change instead of
  only at watch construction, so updateSettings() no longer drops them.
- querySignature's unserializable-parameter fallback is a unique token; two
  distinct unserializable parameter sets can no longer share a signature.
- db.query() routes through db.customQuery(query, defaultExtensions?), preserving
  the subclass extension point.
- Registry disposal is terminal; reopening logs a warning and no-ops.
- Dropped the (db as any) casts, the redundant updateState intersection, moved the
  registry class JSDoc onto the class, and made the TSDoc links resolvable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…changeset [watched-query-plugin-api]

- react/vue useSingleQuery reported source 'live' from its very first render, before
  anything had been read. It now starts at 'placeholder', flips to 'live' when the
  one-shot query resolves, and leaves source untouched on the error path.
- react useQuery: typed the shared loading state instead of widening source with
  `as string`.
- Deleted the React ?raw source-text test: it asserted on source strings rather than
  behaviour, which is already covered in shared-internals and the plugin repo's e2e
  suite. Reverted the vitest include glob that existed only for it.
- Added the missing patch changeset for @powersync/web's README-only change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…init [watched-query-plugin-api]

A superseding updateSettings() racing init()'s waitForReady left the
processor with its closing listener disposed and its schemaChanged
listener never registered: the aborted-generation guard took the same
dispose-and-return path as a real close. The query still linked via the
queued updateSettingsInternal, so it looked healthy while db.close() no
longer cascaded and schema changes silently stopped re-linking it.

Only a closed query takes the early return now; a merely superseded
generation skips its own hook creation (the queued generation owns that
with a fresh signal) but still registers both processor-lifetime
listeners.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… ready [watched-query-plugin-api]

Plugin hooks were created and linked after `await db.waitForReady()`, and the
plugin registry only opened at the end of `initialize()`. A plugin seeding from
its own storage therefore inherited the entire database startup — file open,
version load, schema application, sync-status resolution — which is precisely
the cost seeding exists to skip, and which is largest exactly when the dataset
is big enough for an early paint to matter.

Open the registry before `_initialize()` (it runs before the first await, so it
is open by the time the database constructor returns) and link plugins before
waiting on the database.

Measured on the query-cache plugin, cached time-to-first-rows at 10/50/100/200MB:

  IndexedDB   66-105 ms  ->  3.2-7.0 ms
  OPFS WAL    4.4-11.3 s ->  16.2-53.2 ms

`onDatabaseOpen` is consequently a registration point, not a ready signal: a
plugin needing the database from it must wait for ready itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gartz
gartz force-pushed the persistent-query-cache branch from 4c0c08b to 34d5fef Compare September 19, 2026 18:05
@gartz

gartz commented Sep 19, 2026

Copy link
Copy Markdown
Author

Thanks — this is more useful than a merge would have been.

Withdrawing the plugin API. Your code-size objection is correct: the dispatch sits in AbstractQueryProcessor's constructor and emission path, WatchedQueryState grows two fields, and every user pays whether or not they register a plugin. Wrong trade for an SDK.

The ask, reduced to one thing

db.query({ sql, parameters }).differentialWatch({
  initialData: rows,           // constructed state, synchronously
  initialDataSource: 'cache'   // optional, or drop it
});
  • For a plain watch, a wrapper can already fake this.
  • For a differential watch it can't: DifferentialQueryProcessor.currentMap — the keyed keyBy/compareBy snapshot the next diff runs against — is private and built only from emissions. Seed the visible state without it and the first live emission reports every row as an insert, so the consumer repaints everything and the early paint bought nothing.
  • It has to be usable before the database is ready, which is the part I got wrong in my own implementation. A seed that comes from the plugin's own storage doesn't need SQLite, but my hooks were linked after await db.waitForReady(), so every seeded paint was bounded by exactly the cost it exists to avoid — file open, version load, schema application, sync-status resolution. Those are slowest when the dataset is large enough for seeding to matter, and they're re-paid when streams change. A wrapper can't fix this from outside: it can't paint before the object it wraps exists.
  • Implementation reuses the existing seed path; the comparator is already mandatory here. Cost to everyone else: one optional field read in the constructor.

No second ask — you already shipped it. WatchedQuery is an interface, and useWatchedQuerySubscription is exported from React and Vue generic over it (Query['state']). Combinators compose today, and a wrapper can carry its own source field, so the provenance types in this PR were unnecessary too.

Caching

My published numbers used the default IDBBatchAtomicVFS, not OPFSWriteAheadVFS — the thing you just improved. Re-ran both, six configurations, four sizes. Headless Chromium, median of 3 per run. OPFS figures are min–max across runs, because it varies run to run by up to 40%.

First, where I think you're right. On the default IndexedDB VFS the query is the dominant term. Breaking a 50 MB cold boot into phases — a bare WASQLiteOpenFactory adapter, then what each layer adds:

Phase (50 MB, 38k rows) IndexedDB OPFS, 3 readers
Adapter cold open — worker spawn, module load, VFS handshake 409 ms 1,815 ms
PowerSync.init() above a bare adapter 308 ms 57 ms
First query → rows 6,245 ms 6,239 ms
Cached paint 3 ms 20 ms

The query costs the same on both file systems to within 6 ms — it's the same SQLite doing the same scan — while startup differs by 1.4 s. So on IndexedDB startup is ~0.7 s of a ~9.7 s cold boot. So raw tables, indexes and a faster engine attack the term that actually dominates, and I'm not going to argue otherwise. I'd drafted a "multi-second startup floor" argument and the measurements didn't support it on the configuration most people run.

What the numbers do support is that the cost recurs. The expensive query isn't run once per session — it re-runs on every re-mount:

In-session navigation 10 MB 50 MB 100 MB 200 MB
IndexedDB, no cache 113 ms 7,920 ms 15,699 ms 26,223 ms
OPFS WAL, no cache 50 ms 6,125 ms 11,655 ms 23,004 ms
Cached (memory layer) 0.19–0.42 ms 0.16–0.17 ms 0.18–0.28 ms 0.14–0.19 ms

Back/forward, route changes, a tab returning to the foreground — each one pays the query again. Make the engine 10× faster and a 7 s re-mount becomes 700 ms; it doesn't become 0.18 ms, because the cached path runs no query at all. The gap isn't a speed difference, it's a category one: the memory layer fills the query's constructed state, so the first render already has rows and there's no spinner frame. The query shape is disclosed — ORDER BY over a non-indexed column with a LIMIT, so an index would genuinely cut the uncached column a lot. It doesn't touch the point.

And on mobile the cold start recurs too. Backgrounding a tab can tear down the worker and invalidate file handles, so returning to the foreground pays startup again — sometimes more than a reload would. (Field observation; I haven't benchmarked it.)

Cold boot, for completeness:

Configuration 10 MB 50 MB 100 MB 200 MB
IndexedDB, no cache 2,223 ms 9,688 ms 12,227 ms 28,671 ms
IndexedDB, cached paint 6.0 ms 7.0 ms 4.0 ms 3.2 ms
OPFS WAL (3 readers), no cache 6,320 ms 9,552 ms 22,446 ms 29,318 ms
OPFS WAL, cached paint 18.1 ms 24.6 ms 53.2 ms 16.2 ms

These numbers are the argument for the seam, so it's worth saying how they moved. An earlier draft of this comment had that cached row at 74–103 ms on IndexedDB and 4.4–11.3 s on OPFS. That was a bug in my plugin, not a property of either file system: I linked plugin hooks after await db.waitForReady(), so every seeded paint inherited the whole database startup — precisely the cost seeding exists to skip. Opening the plugin registry before _initialize() and linking before waitForReady() produced the table above. Same harness, same machine, one ordering change.

Which is why initialData has to be usable before the database is ready. That isn't a preference, it's the difference between 16 ms and 10 s — and it's the part a user-land wrapper cannot arrange for itself, because it can't paint before the thing it wraps exists.

  • Read the uncached OPFS row with care. It is not a sound file-system comparison: my "cold boot" creates a fresh PowerSyncDatabase on a live page, and vfsRequiresDedicatedWorkers() means IndexedDB reuses a shared worker across instances while OPFS spawns a dedicated one every time — so it substantially compares worker reuse, not file systems. A sound version needs a page reload per boot, which I can't do inside the test runner. Flagging both because they're the kind of thing that would have wasted your time.
  • What does still hold: past ~60 MB mobile Safari crashes on IndexedDB-backed storage, so the weakest hardware is forced onto OPFS regardless of what the relative costs turn out to be. (Field observation.)
  • It isn't CPU-bound, though I can't isolate this cleanly: throttling the CPU 4× never cost measurable time on the uncached path (0.7–1.4× on IndexedDB; on OPFS every throttled run came out faster than the unthrottled mean, which a slower CPU obviously cannot cause — run-to-run drift is larger than the CPU effect). What survives: less CPU never cost time, on either file system, at any size.
  • init() is not the problem — 57–308 ms over a bare adapter open, so schema application, version load and status resolution aren't where time goes.
  • Caveats, since you'll want to reproduce: headless Chromium in a container is not a phone, and across the pre-fix runs the same OPFS configuration varied by up to 40% between repeats, so treat single figures as samples of a wide distribution rather than constants. The uncached IndexedDB column reproduces the numbers I published earlier, which is the check that the harness itself isn't at fault.
  • What I couldn't measure: the WASM fetch/compile split. Those fetches happen inside the worker and don't reach the page's resource timeline. The module timings I can see are Vite dev-server 304s, which say nothing about a production bundle. If you know a way to instrument inside the worker I'd like to finish it properly. No sync backend in these runs either, so network connect is absent entirely.

So: yes, make the query path faster — on the default VFS that's the dominant term and I was wrong to frame it otherwise. But a seeded initial value removes a different cost, the one paid on every re-mount and every return to the foreground, and that one doesn't shrink with a faster engine.

Instrumentation

Agreed DBAdapter is the better layer for engine cost; pages read/written is more than I could get at watch() level, and I'll contribute a wrapping adapter. It doesn't cover everything, though — these never reach the adapter:

  • which watcher a statement belongs to, and how many are alive (leak detection — we found real ones)
  • re-emission churn: a watcher firing dozens of times a second, each query looking fine
  • time to first rendered result, including throttle window and framework commit
  • results served from cache or placeholder, where no statement executes

Motivation: we can't see these cases from a development machine. Old iPhones and Androids you still support degrade far worse than desktop ratios predict and go unstable with many parallel streams — but that part is field observation, and I want to be clear about which of my claims are measured and which aren't. I tried to stand it up with CPU-throttled runs and they don't support it (the numbers above), so I'm not going to pretend otherwise. Per-watcher measurement in the field is how that stops being anecdote, which is the whole reason I care about the instrumentation question.

Both packages are public as references: powersync-query-cache, powersync-query-logger.

Does it generalize?

# Use case Needs
1 SSR/SSG hydration — first client paint from server rows, no flash seed
2 Devtools panel: live watchers, SQL, row counts, emission rate lifecycle
3 Leak/backpressure guard — cap live watchers, prevent old-device crashes lifecycle
4 Optimistic overlay — pending local writes on every query uniformly seed + observe
5 Retry/backoff on query error (your suggestion) observe
6 Schema validation at the boundary (Zod/Valibot), drift detected once observe
7 keepPreviousData pagination seed
8 Test/Storybook/Playwright fixtures, no real database seed
9 Stale-data UI ("showing saved data") seed (wrapper carries its own tag)
10 Cross-tab result sharing over BroadcastChannel seed + observe
11 Route prefetch on hover, seeded on mount seed
12 OTel/Sentry/Datadog export, slow-query spans observe
13 PII redaction for demos and screenshots observe
14 Encryption at rest for anything persisted seed + observe

Every one is "give me the first value" or "let me watch the lifecycle" — which is the argument against a plugin system. Two narrow primitives beat what I proposed, and one of them already exists.

Tell me what shape you'd want for the seeded initial data and I'll open a fresh PR against that alone. Cache and logger stay outside the SDK either way.

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