@ailura/alpinejs-query-adapter-alpine
v0.2.0
Published
Alpine reactive adapter for @ailura/alpinejs-query — Alpine.reactive backed QueryStateAdapter.
Maintainers
Readme
@ailura/alpinejs-query-adapter-alpine
Registers the
@ailura/alpinejs-querycache as an Alpine store, taking the name from whoever already holds it, and ships theAlpine.reactivevalue box thatquery'sadapteroption consumes. It is 0.68 kB: aQueryController, aguardStorecall withoverride: true, and a four-method factory.
Installation
pnpm add @ailura/alpinejs-query-adapter-alpine @ailura/alpinejs-query alpinejs
# or
npm install @ailura/alpinejs-query-adapter-alpine @ailura/alpinejs-query alpinejsThree peers, all required, none bundled:
alpinejs@^3.0.0— only for the plugin half. The adapter half never touches it.@ailura/alpinejs-core— forguardStoreandresolveStoreKey, the two registration primitives the plugin half is built on.@ailura/alpinejs-query— this package constructs itsQueryControllerand importsQueryStateAdapterfrom it. It is not re-exported here, and that is also the shortest honest description of what this package does.
No package in this toolkit has a dependencies block, so the host installs all
three.
This is one of the seven packages exempt from the strict canon
(ARCHITECTURE.md §9): it has no controller of its own,
no types.ts on the barrel, and it is not a store or a magic — it is a second
way to register the query store.
Usage
1. Alpine
import Alpine from "alpinejs";
import queryAdapterAlpine from "@ailura/alpinejs-query-adapter-alpine";
Alpine.plugin(queryAdapterAlpine());
Alpine.start();That registers $store.query, the same command surface
queryPlugin() from @ailura/alpinejs-query registers, and registers no
magic: there is no $query. Read Store API for what the store
carries and Limitations for what "the same" does and does not
mean.
The three exported factories are the same function. Pick the one whose name says what you mean:
import {
alpineStoreQueryPlugin, // the name the playground catalog advertises
createQueryPlugin, // the implementation
queryAdapterAlpine, // the default export
} from "@ailura/alpinejs-query-adapter-alpine";
Alpine.plugin(alpineStoreQueryPlugin());2. The value-box factory
createAlpineStoreAdapter() is the reactive half of this package, and it is what
@ailura/alpinejs-query's adapter option wants: the controller publishes a
QueryDevtoolsSnapshot on every change, and an Alpine.reactive box is what
makes a template reading that object update.
import Alpine from "alpinejs";
import { createAlpineStoreAdapter } from "@ailura/alpinejs-query-adapter-alpine";
import queryPlugin from "@ailura/alpinejs-query";
Alpine.plugin(queryPlugin({ adapter: createAlpineStoreAdapter(Alpine) }));
// $store.query.devtools.getSnapshot() is now held in a reactive boxUsed on its own, it is a general one-value reactive box:
const adapter = createAlpineStoreAdapter(Alpine);
const box = adapter.create({ count: 0 });
box.get(); // { count: 0 }
box.set({ count: 1 }); // a template bound to the box re-renders
box.destroy(); // get() → undefined, and set() is inert from here onHandles are independent: destroying one leaves every other box readable. Alpine
is a parameter rather than an import, so this is a pure factory with no side
effect at import time, and it works with any host that exposes reactive().
API
| Export | Description | Type |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| createAlpineStoreAdapter | createAlpineStoreAdapter(alpine) => QueryStateAdapter — pure factory, no side effect at import time. This is what queryPlugin({ adapter }) takes | function |
| createQueryPlugin | createQueryPlugin(options?) => (alpine) => void — builds a QueryController and registers toStore() with override: true | function |
| alpineStoreQueryPlugin | Alias of createQueryPlugin under the name the catalog advertises. Its parameter type is narrower: no defaultOptions | function |
| default | queryAdapterAlpine — alias of createQueryPlugin, with the full QueryRegisterOptions parameter type | function |
| DEFAULT_QUERY_STORE_KEY | "query" — the default $store key. The same name and value as query's own constant | string |
| QueryStateAdapter | { create(initial) => { get, set, destroy } } — the value-box contract | type |
| QueryRegisterOptions | { storeKey?, defaultOptions? } | type |
There is no magicKey: the package registers a store and nothing else, so
storeKey is the one name to move.
Store API
Whatever QueryController.toStore() returns, registered by this package instead
of by query's own plugin:
$store.query.observe(["articles"], fetcher, { staleTime: 30_000 });
$store.query.get(["articles"]);
$store.query.invalidate(["articles"]);
$store.query.setData(["articles"], (list) => [...(list ?? []), created]);
$store.query.mutate({ mutationFn: (variables) => api.create(variables) });
$store.query.remove();
$store.query.destroy();
$store.query.devtools.getSnapshot(); // { phase, entries, mutations }
$store.query.devtools.subscribe((s) => console.log(s.entries.length));The full surface, its options, its per-query state and its devtools contract are
documented in @ailura/alpinejs-query — this package does
not extend, wrap or alter it. The two things to know before choosing it:
- There is no
$querymagic. The magic belongs toqueryPlugin(), and this package never registers one. A template that reaches for$queryon a page booted with this plugin getsundefined. - The store does not react to the cache. That is unchanged from
query— see its Store API — and it is also true of the value box:createAlpineStoreAdapter()holds a snapshot, and this plugin does not pass one. The reason the playground's query demo copies what it renders into component state.
Options
type QueryRegisterOptions = {
storeKey?: string; // default: DEFAULT_QUERY_STORE_KEY ("query")
defaultOptions?: unknown; // forwarded verbatim as the controller's QueryOptions
};| Option | Default | Effect |
| ---------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| storeKey | "query" | The $store key. Renaming it is the only collision-avoidance this package offers |
| defaultOptions | — | Handed to new QueryController(undefined, defaultOptions) unchanged, so { staleTime: 30_000 } works. Typed unknown, so nothing checks it and this package re-exports none of query's option types |
defaultOptions is the one option with no default and no validation: pass a
QueryOptions bag or pass nonsense, and the cache will merge it or ignore it
without complaint.
Avoiding name collisions
The usual toolkit rule is that a second package claiming a registered name
throws a RegistrationError. This package is the exception: it registers with
{ override: true }, so it takes "query" from whoever holds it.
Alpine.plugin(queryAdapterAlpine({ storeKey: "cache" }));
// → $store.cacheRenaming storeKey is therefore not needed to avoid a collision — it is needed
to avoid replacing someone else's store without noticing. The playground does
exactly that: it registers this plugin and never registers queryPlugin().
SSR
SSR-safe — no
window/documentat import time, and the registration does not need either. The controller is constructed when the plugin callback runs, which is when the host calls it; nothing here reads the DOM.Alpine.reactiveis only reached insidecreateAlpineStoreAdapter(alpine).create(…), and Alpine is passed in by the caller.
Integration
@ailura/alpinejs-query-adapter-zustandand@ailura/alpinejs-query-adapter-nanostores— the two sibling adapters. All three implement the sameQueryStateAdaptercontract and all three register the key"query", so you can swap one for the other and rename nothing. This one is the only adapter a template can bind to, because itsAlpine.reactivebox is Alpine's own graph; that is also its limit, since nothing outside Alpine can subscribe to a reactive box. Use the zustand or nanostores adapter when a devtools panel, a persistence layer or a test has to observe the published snapshots.@ailura/alpinejs-query— required. This package importsQueryControllerfrom it and registers its store; it does not bundle it and does not add behaviour to it. Register one plugin or the other, not both. The one thing worth taking from that package instead isqueryPlugin({ adapter: createAlpineStoreAdapter(Alpine) }), which registers the same store and the reactive box — this plugin cannot, because it never receives the Alpine instance at factory time.@ailura/alpinejs-json-api— a typedqueryFnfor the cache this package registers, in the shape$store.query.observe(["articles"], () => api.findAll("articles").then((d) => d.data)).
Limitations
- The plugin still does not use the adapter.
createAlpineStoreAdapter()is implemented here and wired up on the other plugin:createQueryPlugin()constructsnew QueryController(undefined, defaultOptions)with no third argument, so a store registered by this package gets no value box and nothing in the cache becomes reactive. To get the box, registerqueryPlugin()from@ailura/alpinejs-querywith{ adapter: createAlpineStoreAdapter(Alpine) }— and then you do not need this plugin at all. - The two
QueryStateAdaptertypes are structurally identical but separately declared — one here, one in@ailura/alpinejs-query. The contract is satisfied by construction and checked by neither compiler, because neither package imports the other's type. A change to one is invisible to the other. - The box holds a snapshot, not an entry. What the adapter receives is a
QueryDevtoolsSnapshotof the whole cache. It does not makestore.get(key)reactive, and it is not a store — a template that wants the latest snapshot has to hold the box itself. override: truebypasses the registration guards on purpose. Registering this plugin afterqueryPlugin()replaces$store.querysilently, and the first controller is left running with its in-flight requests and no way to reach it. Nothing warns.- Three names, one function.
createQueryPlugin, the default export (queryAdapterAlpine) andalpineStoreQueryPluginare the same code. They do not even have the same parameter type:alpineStoreQueryPluginomitsdefaultOptionsfrom its options type, so passing one to that alias is a compile error while passing it to either of the other two is not. DEFAULT_QUERY_STORE_KEYcollides by name withquery's own constant. Both are"query", both are exported, and importing both into one module needs an alias. They are not the same binding, so nothing keeps them in step.src/types.tsis not on the barrel.AdapterOptionsandDEFAULT_ADAPTER_ALPINE_STORE_KEYare unreachable from the package entry point and nothing insrc/imports that file. The value is the same asDEFAULT_QUERY_STORE_KEY; the type is a subset ofQueryRegisterOptions.defaultOptionsis untyped and unvalidated — see Options.- No test covers the plugin.
test/adapter.test.tsexercisescreateAlpineStoreAdapteronly;createQueryPluginand its two aliases are untested, which is the other reason theoverride: truebehaviour above has gone unnoticed. - The budget is met with room to spare —
0.40 kB gzipagainst a3 kBentry — so the size gate is not a constraint on this package.
Size
0.68 kB raw / 0.40 kB gzip · budget 3 kB · externalized peers: alpinejs, @ailura/alpinejs-core, @ailura/alpinejs-query · size-limit + publint + attw verified.
Architecture
Data layer — a canon exception by design, listed in
ARCHITECTURE.md §9: the sole QueryStateAdapter
implementation, counted once among the seven WARN rows. It has no controller of
its own and no state of its own — it constructs another package's controller and
registers it, which is why it bundles 0.68 kB and why plugin.ts here has no
change listener to wire.
Testing
pnpm exec vp test packages/query-adapter-alpine
pnpm exec tsc --noEmit -p packages/query-adapter-alpine/tsconfig.jsontest/adapter.test.ts observes the adapter from the outside only — what get()
returns, what set() changes, what destroy() releases — through a mock Alpine
that keeps the boxes it handed out. The plugin has no tests; see
Limitations. See
ARCHITECTURE.md §8.
License
MIT
