fs-adapter-store
Reactive state management with CRUD resource adapters.
npm install @script-development/fs-adapter-storePeer dependencies: vue ^3.5.33, @script-development/fs-http ^0.1.0 || ^0.2.0 || ^0.3.0, @script-development/fs-storage ^0.1.0, @script-development/fs-loading ^0.1.0, @script-development/fs-helpers ^0.1.0
What It Does
fs-adapter-store is the domain layer package. It provides reactive, per-domain state management with built-in CRUD operations. Think of it as a lightweight alternative to Pinia that's designed for REST API resources — it fetches data, stores it reactively, and gives you adapted objects with update(), patch(), delete(), and create() methods.
The Big Picture
A typical application has domain resources — users, projects, invoices — that need to be:
- Fetched from an API
- Stored in reactive state
- Displayed in components
- Edited through forms
- Saved back to the API
fs-adapter-store handles all of this with a single createAdapterStoreModule() call per resource.
Basic Usage
1. Define Your Domain Type
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
}2. Create the Store Module
import {createAdapterStoreModule, resourceAdapter} from '@script-development/fs-adapter-store';
import {http, storage, loading} from '@/services';
const usersStore = createAdapterStoreModule<User>({
domainName: 'users', // API endpoint: /users
adapter: resourceAdapter, // CRUD adapter factory
httpService: http, // for API calls
storageService: storage, // for offline persistence
loadingService: loading, // for waiting on data
});3. Fetch and Display
// Fetch all users from the API
await usersStore.retrieveAll();
// Reactive list of all users
const allUsers = usersStore.getAll; // ComputedRef<Adapted<User>[]><script setup lang="ts">
import {usersStore} from '@/stores';
</script>
<template>
<ul>
<li v-for="user in usersStore.getAll.value" :key="user.id">{{ user.name }} — {{ user.email }}</li>
</ul>
</template>4. Edit and Save
Each adapted resource has a mutable ref for editing and methods for saving:
<script setup lang="ts">
const user = usersStore.getById(42); // ComputedRef<Adapted<User> | undefined>
</script>
<template>
<form v-if="user.value" @submit.prevent="user.value.update()">
<input v-model="user.value.mutable.value.name" />
<input v-model="user.value.mutable.value.email" />
<button type="submit">Save</button>
<button type="button" @click="user.value.reset()">Reset</button>
</form>
</template>5. Create New Resources
const newUser = usersStore.generateNew();
newUser.mutable.value.name = 'Alice';
newUser.mutable.value.email = 'alice@example.com';
await newUser.create(); // POST /users → adds to storeAdapted vs NewAdapted
The adapter pattern creates two distinct object types:
Adapted (Existing Resources)
When a resource comes from the API (has an id), it's wrapped in an Adapted object:
const user = usersStore.getById(1).value;
// Read original values (readonly, frozen)
user.id; // 1
user.name; // "Alice"
user.email; // "alice@example.com"
// Edit via mutable ref
user.mutable.value.name = 'Bob';
// Save changes
await user.update(); // PUT /users/1 — sends full object
await user.patch({name: 'Bob'}); // PATCH /users/1 — sends partial update
// Discard edits
user.reset(); // mutable reverts to original values
// Delete
await user.delete(); // DELETE /users/1 — removes from storeNewAdapted (Unsaved Resources)
When you create a new resource via generateNew(), it's wrapped in a NewAdapted object:
const newUser = usersStore.generateNew();
// Default values (readonly, frozen)
newUser.name; // "" (empty defaults)
newUser.email; // ""
// Edit via mutable ref
newUser.mutable.value.name = 'Alice';
// Save to API
await newUser.create(); // POST /users → returns full User with id
// Reset to defaults
newUser.reset();Why two types?
An existing resource has update(), patch(), and delete(). A new resource has only create(). TypeScript enforces this — you can't accidentally call delete() on something that hasn't been saved yet.
Waiting for Data
getOrFailById waits for loading to complete before looking up the resource. This is useful when navigating to a detail page where data might still be loading:
try {
const user = await usersStore.getOrFailById(42);
// user is guaranteed to exist
} catch (error) {
if (error instanceof EntryNotFoundError) {
// user 42 doesn't exist in the store
redirectTo404();
}
}Offline Persistence
The store automatically persists state to the provided storage service. When the page reloads, stored data is available immediately while retrieveAll() fetches fresh data from the API. This provides a fast initial render without loading spinners.
Syncing External Updates
Some resources are updated outside of the store's own CRUD calls — by another user over a WebSocket, by a background job, by an in-process event emitter. The broadcast config slot is the single, narrow bridge for feeding those updates into the store without going through HTTP.
import type {AdapterStoreBroadcast} from '@script-development/fs-adapter-store';
const broadcast: AdapterStoreBroadcast<User> = {
subscribe: ({onUpdate, onDelete}) => {
eventSource.on('user.updated', onUpdate);
eventSource.on('user.deleted', onDelete);
return () => {
eventSource.off('user.updated', onUpdate);
eventSource.off('user.deleted', onDelete);
};
},
};
const usersStore = createAdapterStoreModule<User>({
domainName: 'users',
adapter: resourceAdapter,
httpService: http,
storageService: storage,
loadingService: loading,
broadcast,
});The store calls subscribe exactly once at construction and wires the handlers straight into its internal mutation path. onUpdate(item) replaces or inserts; onDelete(id) removes. Both update reactive state, refresh adapted views, and persist to storage — identical to what update() / delete() do after a successful HTTP call.
Why isn't there a public setById / applyUpdate method?
By design. Exposing a raw mutation method would let any caller bypass HTTP, which is almost always a bug (you'd end up with stale server state). The broadcast contract forces the bridge to be declared explicitly at store construction, scoped to one event source per store.
The handlers the store passes to your subscribe are validating wrappers, not the raw internal mutators. Because broadcast payloads come from an external channel and are applied without an HTTP round-trip, they are checked before they touch state: onUpdate requires an object with an integer id, onDelete requires an integer id (NaN / Infinity / non-integer floats are rejected — they pass a typeof === 'number' check but corrupt the keyspace). A payload that fails throws BroadcastPayloadError rather than corrupting the store. The raw mutators never leave the factory — and since this is a closed contract, don't re-export the handlers onto your own public surface, which would publish a non-HTTP write path for arbitrary callers.
Lifecycle
The subscribe call happens once, when the store is created. The unsubscribe return is retained internally and never exposed. In practice stores live for the app's lifetime, so teardown isn't needed — but if your event source has its own lifecycle (e.g., a channel you join and leave), manage that outside the store. The store only cares about incoming events, not which channel they came from.
A common pattern is a small in-process emitter as a middleman: your transport layer (WebSocket, SSE, channel service, whatever) joins and leaves connections as views mount/unmount, and forwards incoming payloads onto an emitter that the store subscribes to. The store stays agnostic of transport and lifecycle.
The Contract
type AdapterStoreBroadcast<T> = {
subscribe: (handlers: {onUpdate: (item: T) => void; onDelete: (id: number) => void}) => () => void; // unsubscribe
};That's it. Any event source that can emit "updated" and "deleted" events for your resource type can implement this.
Extending the Store
Some stores need a domain-specific fetch that the built-in surface doesn't cover — for example, retrieving one resource by a string route-binding key rather than its numeric id. The extend config slot is a capability-injection hook for exactly this: it lets a consumer add its own store-level methods without app-specific concepts leaking into the package, and without ever exposing a raw mutator.
extend runs once at store construction and receives an ExtendCapabilities<T> surface whose only ingest path is retrieveInto(endpoint, options?) — it performs an HTTP GET and upserts the (validated) response into the store. It returns an object of consumer-defined methods, which are merged onto the public store surface.
const usersStore = createAdapterStoreModule<
User,
Adapted<User>,
NewAdapted<User>,
{retrieveBySlug: (slug: string) => Promise<void>}
>({
domainName: 'users',
adapter: resourceAdapter,
httpService: http,
storageService: storage,
loadingService: loading,
extend: ({retrieveInto}) => ({retrieveBySlug: (slug: string): Promise<void> => retrieveInto(`users/${slug}`)}),
});
// The custom method is on the public surface, fully typed — no cast needed
await usersStore.retrieveBySlug('alice');
const alice = usersStore.getById(alice.id);Why retrieveInto, not raw setById. extend's return value is the public store surface. Were it handed the bare setById, a consumer could re-export a non-HTTP write path — extend: (cap) => ({save: cap.setById}), or a (item) => cap.setById(item) wrapper that no runtime guard can tell apart from a legitimate fetch-then-set. Routing the only ingest through retrieveInto makes that structurally unexpressible, not merely guarded: the sole way data enters the store via extend is an HTTP response. This matters for consumer territories under ISO 27001 / NEN 7510, where the HTTP path is where authz and audit live. extend still closes over the consumer's whole module scope, so custom endpoints, derived methods, and cross-store coordination all stay expressible — only "write state with no server response behind it" is removed.
This is the asymmetry with broadcast: broadcast's non-HTTP write path is irreducible (it is the feature — applying server-pushed events without a round-trip), so it can only be validated; extend's isn't, so it is designed out.
Validation. retrieveInto validates every item the response yields (a single item or an array): each must be an object with an integer id (NaN / Infinity / non-integer floats are rejected — they pass a typeof === 'number' check but corrupt the keyspace). A malformed item throws ExtendPayloadError rather than corrupting state — so a buggy backend response can't silently poison the store.
Returned keys must be new names. A key that collides with a built-in store method (getAll, getById, getOrFailById, generateNew, retrieveById, retrieveAll) throws ExtendKeyCollisionError at construction — always, on every call form. It is additionally a compile error when you pass the extend shape as the fourth type argument (as in the example above) — which is the form you use to make the extended methods callable. Passing the type argument therefore gives you both the editor-time guarantee and a callable method; the runtime guard is the backstop that holds even on a bare <T, E, N> call where the extend shape is left to default.
The guard keys on the current built-in set, so it carries a forward-compat consequence: adding a built-in method in a future release will collide with any extend method already shipping that name — i.e. a new built-in is a breaking change for extend-consumers. Worth keeping in mind when naming extend methods (and when evolving the built-in surface).
Custom New Types
By default, generateNew() creates an object with all fields except id. You can customize this with a third type parameter:
interface CreateUserData {
name: string;
email: string;
// no role — assigned server-side
}
const usersStore = createAdapterStoreModule<User, Adapted<User, CreateUserData>, CreateUserData>({
domainName: 'users',
adapter: resourceAdapter,
httpService: http,
storageService: storage,
loadingService: loading,
});
const newUser = usersStore.generateNew();
// newUser.mutable has only name and email — no role fieldError Handling
The package exports five error classes:
import {
BroadcastPayloadError,
EntryNotFoundError,
ExtendKeyCollisionError,
ExtendPayloadError,
MissingResponseDataError,
} from '@script-development/fs-adapter-store';EntryNotFoundError— thrown bygetOrFailByIdwhen the resource doesn't exist in the storeMissingResponseDataError— thrown when a CRUD response doesn't contain adatafieldBroadcastPayloadError— thrown by abroadcasthandler when the incoming payload is malformed (onUpdatenot given an object with an integerid, oronDeletegiven a non-integer id)ExtendKeyCollisionError— thrown at store construction when anextendmethod's key collides with a built-in store methodExtendPayloadError— thrown byextend'sretrieveIntowhen a fetched item is not an object with an integerid(a malformed backend response cannot corrupt the keyspace)
API Reference
createAdapterStoreModule(config)
| Parameter | Type | Description |
|---|---|---|
config.domainName | string | Resource endpoint name (e.g., "users") |
config.adapter | Adapter | CRUD adapter factory (use resourceAdapter) |
config.httpService | Pick<HttpService, "getRequest"> | HTTP service for fetching |
config.storageService | Pick<StorageService, "get" | "put"> | Storage for persistence |
config.loadingService | Pick<LoadingService, "ensureLoadingFinished"> | Loading service for sync |
config.broadcast? | AdapterStoreBroadcast<T> | Optional external-event bridge for server-initiated updates |
config.extend? | (cap: ExtendCapabilities<T>) => X | Optional capability-injection hook; cap.retrieveInto(endpoint, options?) is the sole ingest path. Merges consumer-defined methods onto the store surface |
Store Module Methods
| Method | Returns | Description |
|---|---|---|
getAll | ComputedRef<Adapted[]> | Reactive list of all adapted resources |
getById(id) | ComputedRef<Adapted | undefined> | Reactive lookup by ID |
getOrFailById(id) | Promise<Adapted> | Wait for loading, throw if not found |
generateNew() | NewAdapted | Create a new unsaved resource |
retrieveById(id) | Promise<void> | Fetch a single resource from the API by id |
retrieveAll() | Promise<void> | Fetch all from API and update state |
Adapted Properties
| Property | Type | Description |
|---|---|---|
| (all resource fields) | readonly | Original values from API |
mutable | Ref<Writable<T>> | Editable copy |
reset() | () => void | Revert mutable to original |
update() | () => Promise<T> | PUT full resource |
patch(partial) | (partial) => Promise<T> | PATCH partial update |
delete() | () => Promise<void> | DELETE resource |
NewAdapted Properties
| Property | Type | Description |
|---|---|---|
| (all new fields) | readonly | Default values |
mutable | Ref<Writable<N>> | Editable copy |
reset() | () => void | Revert mutable to defaults |
create() | () => Promise<T> | POST to create resource |