@caspian-vega/nano-macro-contracts
v0.4.1
Published
Lightweight, opinionated contracts built on top of nanostores: defineStore (chain-composed macro-stores with state, async resources, computes and actions) and a small set of RxJS-like store operators.
Maintainers
Readme
@caspian-vega/nano-macro-contracts
Store contracts on top of nanostores. defineStore
composes state, async resources, derived values, actions and effects into one class where every
member is a real nanostore.
Part of caspian-vega-astro-libs.
Install
npm install @caspian-vega/nano-macro-contracts nanostores @nanostores/async @nanostores/logger
# or
pnpm add @caspian-vega/nano-macro-contracts nanostores @nanostores/async @nanostores/loggernanostores, @nanostores/async and @nanostores/logger are peer dependencies.
Usage
import { browserOnly, debounced_, defineStore } from '@caspian-vega/nano-macro-contracts/macro-store';
import { logger } from '@nanostores/logger';
import { computed } from 'nanostores';
class PostStore extends defineStore('PostStore', { query: '', topicId: '' })
.resource('posts', {
pipe: browserOnly(debounced_(300)),
load: async ({ query, topicId }) => fetchPosts({ query, topicId }),
initial: [] as Post[],
})
.derive((s) => ({
postCount: computed(s.posts.value, (posts) => posts.length),
}))
.actions((s) => ({
setQuery: (query: string) => s.patch({ query }, 'setQuery'),
}))
.logger(import.meta.env.DEV ? logger : undefined)
.build() {}
const store = new PostStore();
await store.posts.ready();
store.posts.value.get();
store.setQuery('astro');Each step returns a newly typed builder, so the store argument of every later callback is inferred
from what was registered before it.
Entry points
| Import | Contains |
| ------------------- | ------------------------------------------------------------------------------------------------- |
| . | Everything below |
| ./macro-store | defineStore, browserOnly, createResource, assertStoreDefinition, RESERVED_STORE_MEMBERS |
| ./nanostore-utils | pipeLine and the operators |
| ./nanostore-ready | whenReady |
| ./nanostore-lite | NanoContract, NanoPromise |
Chain order
flowchart LR
D["defineStore(name, initial)"] --> F["from(source)"]
D --> S["standalone()"]
D --> R["resource(name, def)"]
F --> R
S --> R
R --> P["deps(factory)"]
D --> P
P --> V["derive(factory)"]
V --> A["actions(factory)"]
A --> E["effect(factory)"]
E --> L["logger(fn, options)"]
L --> X["extend(factory)"]
X --> B["build()"]Steps may repeat and may be ordered freely. A factory only sees members and dependencies declared above it.
API
defineStore(name, initialState?)
Starts a chain.
name(string, required): shown in logger output and in collision errors.initialState(object, optional): the state shape. Constrained toobject, so an interface is accepted withsatisfies, withas, or as an annotated constant.- Returns:
StoreBuilder, carryingstate,snapshot,patch,setState,resetanddestroy.
defineStore('ProductStore', { q: '', category: null } satisfies ProductFilterState);Chain steps
| Step | Adds |
| ----------------------------------- | --------------------------------------------------------- |
| .resource(name, def) | a Resource<TData> driven by the store's state |
| .from(source).resource(name, def) | a resource driven by a narrower projection |
| .standalone().resource(name, def) | a resource driven by nothing, refreshed by reload() |
| .deps(factory) | private dependencies, not members |
| .derive(factory) | derived nanostores |
| .actions(factory) | methods placed on the instance |
| .effect(factory) | a side effect, a returned function becomes cleanup |
| .logger(fn, options?) | binds state to a logger and enables patch(..., label) |
| .extend(factory) | non-store, non-function members |
| .build() | the store class |
Extend the built class to get a named, DI-friendly type:
class PostStore extends defineStore('PostStore', {}).build() {}Base members
| Member | Signature | Produces |
| ---------------------- | ------------------------------------------------------------- | ------------------------------------------------ |
| state | MapStore<TState> | the nanostore, usable with computed(...) |
| snapshot | TState | synchronous read of the current value |
| patch(patch, label?) | Partial<TState> \| ((s: TState) => Partial<TState>), string | merges a partial, label names the action |
| setState(next) | TState | replaces the whole state |
| reset() | | restores a shallow copy of the initial state |
| destroy() | | runs cleanups and unbinds the logger, idempotent |
.resource(name, def)
Adds an async member driven by the store's state.
name(string, required): member name.def.load((source, store, deps) => Promise<TData>, required):sourceis the state, or the.fromprojection.def.initial(TData, required): value ofresource.valuebefore the first load.def.pipe(StoreOperator<TSource>[], optional): operators applied to the source.def.enabled((source) => boolean, optional): returnfalseto skip the load and keep the current value.- Returns: the builder, with
Resource<TData>undername.
.resource('posts', {
pipe: browserOnly(debounced_(300)),
enabled: ({ query }) => query.length > 2,
load: ({ query }) => api.searchPosts(query),
initial: [] as Post[],
})Resources are lazy. The loader runs when something first subscribes, or when ready() is awaited,
not when the store is constructed. value keeps the last successful value while a reload is in
flight or has failed.
The source is the whole state map, so every patch(...) re-runs the loader. For a parameter-free
loader such as getAllGreetings(), use .standalone() instead.
.from(source)
Narrows what the next resource reloads on.
source((store) => ReadableAtom<TSource>, required)- Returns:
ScopedStoreBuilder, whose.resource(...)receivesTSourceinstead of the state.
defineStore('TopicStore', { topicId: '', unrelatedFilter: '' })
.from((s) => computed(s.state, ({ topicId }) => topicId))
.resource('topic', {
load: (topicId) => fetchTopic(topicId),
initial: null,
});.standalone()
Detaches the next resource from the state entirely, for a loader that takes no input.
- Takes no arguments.
- Returns:
StandaloneStoreBuilder, whose.resource(...)takes aStandaloneResourceDef.
const GreetingStore = defineStore('GreetingStore', { filter: '' })
.deps(() => ({ api: svInject(GreetingApi) }))
.standalone()
.resource('greetings', {
load: (_store, { api }) => api.getAllGreetings(),
initial: [] as Greeting[],
})
.build();
const store = new GreetingStore();
await store.greetings.ready(); // first load
store.greetings.reload(); // every later refreshThe loader drops the dead source argument: it receives the store and the deps bag directly. pipe
and enabled are not offered, having nothing to act on.
- Without
.standalone(), the source is the whole state map, so everypatch(...)re-runs the loader even when it reads nothing from state. This is the reason the step exists. - The resource stays lazy. It loads when something first subscribes or
ready()is awaited, never at construction. reload()before the first subscription does not load early. The pending trigger folds into the first load.- While a reload is in flight,
loadingistrueandvaluestill holds the previous result. - A failed load leaves
errorset andvalueuntouched. The nextreload()clears the error on success. - The constant source is created per instance, so two instances of the same store reload independently.
Standalone and state-reactive resources mix freely in one chain:
defineStore('CatalogStore', { id: '' })
.standalone()
.resource('catalog', { load: (_s, d) => d.api.getCatalog(), initial: [] })
.from((s) => computed(s.state, ({ id }) => id))
.resource('item', { load: (id, _s, d) => d.api.getItem(id), initial: null });.deps(factory)
Registers collaborators the store uses but does not expose.
factory((deps) => object, required): runs once per instance, at construction.- Returns: the builder. Every later factory receives the bag as its second argument, a resource loader as its third.
class GreetStore extends defineStore('GreetStore', { greeting: 'hello' })
.deps(() => ({ userService: svInject(UserService) }))
.actions((s, d) => ({ greet: () => d.userService.sendGreeting(s.snapshot.greeting) }))
.build() {}
const store = new GreetStore();
store.greet();
Object.keys(store); // ['greet']- Dependencies are not members. The bag lives in a module-private
WeakMap, so it is absent fromObject.keys, spreads,JSON.stringifyandsnapshot. - Own namespace, so a dependency may share a name with a member. Only a dependency declared twice collides.
- Several
.deps(...)steps accumulate. Each bag is frozen. - Dependencies are borrowed, not owned. There is no teardown hook. Allocate in
.effect(...)instead.
.derive(factory)
factory((store, deps) => Record<string, ReadableAtom<any>>, required)- Returns: the builder, with each returned atom as a member.
.derive((s) => ({ total: computed(s.state, ({ items }) => sum(items)) })).actions(factory)
factory((store, deps) => Record<string, Function>, required)- Returns: the builder, with each returned function as an instance method.
.actions((s) => ({ add: (item: Item) => s.patch({ items: [...s.snapshot.items, item] }, 'add') })).effect(factory)
factory((store, deps) => void | (() => void), required): a returned function is registered as cleanup and runs ondestroy().- Returns: the builder.
.effect((s) => {
const off = s.state.listen(persist);
return off;
}).logger(fn, options?)
Binds state to @nanostores/logger, or any drop-in with the same signature.
fn(StoreLoggerFn | undefined, required):undefinedis a no-op.options(LoggerOptions, optional): defaults suppress mount and unmount noise.- Returns: the builder. Once bound, the
labelargument ofpatch(...)appears as a named action.
.logger(import.meta.env.DEV ? logger : undefined).extend(factory)
Escape hatch for public members that are neither stores nor functions.
factory((store, deps) => object, required)- Returns: the builder.
.extend(() => ({ formatter: new Intl.NumberFormat() })).build()
- Returns:
StoreConstructor<TShape>, a class. It runs no factories. Every factory runs once per instance, at construction.
assertStoreDefinition(Store)
Constructs the class once and destroys it, so collisions surface in a unit test instead of in a request.
Store(new () => StoreLifecycle, required)- Returns:
void - Throws:
Errorlisting every collision in the definition.
it('is a valid store definition', () => {
expect(() => assertStoreDefinition(PostStore)).not.toThrow();
});browserOnly(...operators)
operators(StoreOperator<T>[], required)- Returns: the operators on the client, an empty array on the server. Keeps timers out of SSR.
pipe: browserOnly(debounced_(300));createResource(store, source, def, deps)
Low-level constructor behind .resource(...). Use the chain step unless a resource is needed outside
a store.
- Returns:
Resource<TData, TError>
RESERVED_STORE_MEMBERS
ReadonlySet<string> holding state, snapshot, patch, setState, reset, destroy.
pipeLine(store, ...operators)
Applies operators left to right.
store(ReadableAtom<T> | WritableAtom<T>, required)operators(StoreOperator<T>[], required)- Returns: the same atom kind that went in.
const query = pipeLine(
input,
debounced_(250),
distinct_(),
sideEffect_((v) => console.log(v))
);Operators
| Operator | Curried form | Produces |
| -------------------------------------- | -------------------- | ----------------------------------------- |
| debounced(store, delay = 300) | debounced_(delay) | emits after the value stops changing |
| throttled(store, delay = 300) | throttled_(delay) | emits at most once per interval |
| delayed(store, delay = 300) | delayed_(delay) | emits every value, late |
| filter(store, predicate) | filter_(predicate) | emits only values passing the predicate |
| distinct(store, compare = Object.is) | distinct_(compare) | drops consecutive equal values |
| sideEffect(store, fn) | sideEffect_(fn) | runs fn per value and passes it through |
All return WritableAtom<T>. Subscriptions and timers are allocated only while the derived atom has
listeners.
whenReady(stores, predicate, timeout?)
Waits until one or more stores satisfy a predicate.
stores(ReadableNanoStore<T>or an array of them, required): anything withgetandlisten.predicate((...values) => boolean, required)timeout(number, default3000): milliseconds.- Returns:
Promise<WhenReadyResult>carryingready,timedOutandvalues. Never rejects.
await whenReady(store.posts.value, (posts) => posts.length > 0);
await whenReady([userAtom, settingsAtom], (user, settings) => !!user && !!settings, 5000);Subscribing mounts a lazy store and triggers its work. The subscription is released once the gate settles.
Types
Resource
async(ReadableAtom<AsyncValue<TData>>): raw lifecycle.value(ReadableAtom<TData>): last good value, seeded byinitial.loading(ReadableAtom<boolean>)error(ReadableAtom<TError | undefined>)reload(): voidready(timeout?): Promise<ResourceReadyResult<TData, TError>>: never rejects.
ResourceReadyResult
value(TData, optional)error(TError, optional)timedOut(boolean)
ResourceDef
Definition object accepted by .resource(name, def). Fields are listed under .resource above.
StandaloneResourceDef
Definition object accepted by .standalone().resource(name, def).
load((store, deps) => Promise<TData>, required)initial(TData, required)
StoreOperator
(store: ReadableAtom<T>) => WritableAtom<T>
WhenReadyResult
ready(boolean)timedOut(boolean)values(readonly tuple of the store values)
MacroStoreDestroyable
onMacroStoreDestroyed(): void
Optional teardown hook for a subclass that owns resources the chain never saw. It is looked up on
the instance at teardown and runs before the chain cleanups, so derived atoms, resources and actions
are still usable inside it. If it throws, the chain cleanups still run and the error propagates out
of destroy().
class SocketStore extends defineStore('SocketStore', { url: '' }).build() implements MacroStoreDestroyable {
private readonly socket = new WebSocket(this.snapshot.url);
onMacroStoreDestroyed(): void {
this.socket.close();
}
}NanoContract
WritableAtom<T> | PreinitializedWritableAtom<T>, a writable store contract.
NanoPromise
ReadableAtom<AsyncValue<T>>, a read-only async value.
ReadableNanoStore
Minimal shape accepted by whenReady: get() plus listen(cb).
Others
StoreBuilder, ScopedStoreBuilder, StoreConstructor, StateApi, StoreLifecycle,
StoreLoggerFn, StoreState, StoreDeps, AnyAtoms, AnyActions, StoreMemberConflict,
NoOverlap, FreeName, NoDepOverlap, StoreDepConflict. These support inference in the chain
and are rarely written by hand.
Collisions
Member names share one flat namespace. A collision, against a reserved name or against a member an earlier step defined, is caught twice.
- At compile time, on the offending key:
.actions(() => ({ reset: () => {} }))
// error: Type '() => void' is not assignable to type
// '(() => void) & StoreMemberConflict<"store member \"reset\" is already defined">'- At the first construction, with the store name, the chain step, and every collision in one error:
[InquiryStore] .actions() cannot define "reset": it is a reserved store member
(state, snapshot, patch, setState, reset, destroy).
Rename it, or call the built-in store.reset().Only the six reserved names are taken. Object.prototype members such as toString are ordinary
member names.
Constraints
- Factories run once per instance, at construction.
build()runs none of them, so a collision surfaces at first construction. UseassertStoreDefinition(...)in a test to force it in CI. - A factory that throws fails construction and the original error propagates. Collisions already
collected are reported instead, with the throw attached as
cause. The half-built instance is destroyed either way. - Allocating in
.extend(...)or.derive(...)leaks unless teardown is handed todestroy():
// leaks
.extend(() => ({ conn: openConn() }))
// hands over teardown
.extend(() => {
const conn = openConn();
return { conn, onMacroStoreDestroyed: () => conn.close() };
})
// inert holder, effect acquires and releases
.extend(() => ({ conn: lazyConn() }))
.effect((s) => { s.conn.open(); return () => s.conn.close(); })- An
.extend(...)hook shadows a subclass hook. The extend factory defines an own property duringsuper(), a subclass method lives on the prototype, so the own property wins and no collision is reported. Use one or the other, never both. reset()restores a shallow copy of the initial state, so nested objects keep their original references. Pass a freshstructuredClone(INITIAL)per store when state holds mutated objects.- Members are non-writable but configurable, so
vi.spyOn(store, 'submit')works.
Synergies
See SYNERGIES.md.
License
MIT
