Conversation
🦋 Changeset detectedLatest commit: 34d5fef The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
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 |
|
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 It's a shame that the JS ecosystem doesn't really have standard transformations for this primitive (unlike say CachingIMHO, 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
I think this is a great feature to have. We kind of implement this via the I think We have incrementally made |
…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>
4c0c08b to
34d5fef
Compare
|
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 The ask, reduced to one thingdb.query({ sql, parameters }).differentialWatch({
initialData: rows, // constructed state, synchronously
initialDataSource: 'cache' // optional, or drop it
});
No second ask — you already shipped it. CachingMy published numbers used the default 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
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:
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 — 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:
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 Which is why
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. InstrumentationAgreed
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?
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. |
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:
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/webin 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.
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:
API surface (all
@alpha)In
@powersync/common:WatchedQueryPlugin—id, optionalonDatabaseOpen(ctx)(once ready; may return a disposer run on close), optionalonWatchedQueryCreate(ctx)returning per-query hooks.WatchedQueryHooks—seedInitial()(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 }.WatchedQueryStategainssource: stringandsourceMeta: unknown.extensions: Record<string, unknown>— per-query, per-plugin options (e.g.extensions: { cache: false }); definition-level extensions merge with per-call ones.plugins: WatchedQueryPlugin[]; the database emits aclearedevent fromdisconnectAndClear().Core semantics
sourceto'live'and clearssourceMeta. A seeded state reportsisLoading: false(there is data to render) but keepsisFetching: true.extensions[plugin.id]entry; no plugin-to-plugin communication.onDatabaseOpenruns before any query hooks; hooks for queries created pre-ready are deferred and created after open.Changes by package
@powersync/common— contract types, provenance fields,extensions,pluginsoption,clearedevent (minor).@powersync/shared-internals— plugin registry, query-signature helper, hook dispatch in the query processors (minor).@powersync/react,@powersync/vue— hooks/composables surfacesource/sourceMetaand threadextensions(patch).@powersync/web— README section documenting the plugin option (patch).Testing
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-internals98/98,common29/29,pnpm build:packagesclean.@powersync/webin headless Chromium): cold-boot cache paint before the live query resolves,disconnectAndClear()wiping, per-query opt-out.🤖 Generated with Claude Code