svas
v3.1.0
Published
Svelte stores for asynchronous data with fetching, caching, revalidation, and persistence.
Readme
Svelte Async Stores
Svelte stores for asynchronous data with fetching, caching, revalidation, and persistence.
Everything below this line is written by AI.
Maybe<T>convention (null | T | Error) for all async states- Built-in revalidation with stale-while-revalidate
- Optional
localStorage/sessionStoragepersistence - Composable utilities for guards, awaits, and combinators
TL;DR
npm i svas<script lang="ts">
import { value, ok } from 'svas'
const user = value<User>({
get: () => fetch('/api/me').then((r) => r.json()),
persist: 'user'
})
</script>
{#if $user instanceof Error}
<p>Failed to load</p>
{:else if $user === null}
<p>Loading…</p>
{:else}
<p>Hello, {$user.name}</p>
{/if}Conventions
All async stores expose their state as Maybe<T, E> = null | T | E where E extends Error:
null— no data yet (loading, cleared, or not fetched)T— resolved valueE— fetch error
Stores share a common lifecycle:
- Fetch is triggered lazily on first
subscribe - Revalidation happens automatically after
revalidatems (default300_000) stale: truekeeps the previous value visible while revalidatingbind: Readable<unknown | null>nullifies the store when the bound store becomesnull(useful for tying data to a session/user)persist: stringmirrors the store intolocalStorageunder the given key
Stores
value<T>(options)
A single asynchronous value.
import { value } from 'svas'
const profile = value<Profile>({
get: () => api.getProfile(),
revalidate: 60_000,
persist: 'profile',
session: false,
default: null,
bind: session
})Interface:
class Value<T> implements Writable<T | null> {
subscribe(run): Unsubscriber // triggers sync on first subscribe
set(value: T | null): void
update(updater): void
extract(): T | null // synchronous read
sync(): void // revalidate if stale
}Options:
get?: () => Promise<T | Error>— fetcherrevalidate?: number— ms before re-fetching (default300_000)persist?: string— storage keysession?: boolean— usesessionStorageinstead oflocalStoragedefault?: T— initial value when no persisted data existsbind?: Readable<unknown | null>— reset todefaultwhen bound store isnull
values<T>(options)
A keyed cache of asynchronous values. Each key has its own Readable<Maybe<T>> lifecycle.
import { values } from 'svas'
const products = values<Product>({
get: (id) => api.getProduct(id),
revalidate: 60_000,
permanent: false,
stale: true,
persist: 'products',
bind: session
})
const product = products.get('42')Interface:
class Values<T, E extends Error = Error> {
get(key, options?: { fetch?: boolean }): Readable<Maybe<T, E>>
set(key, value: T | E, options?: { stash?: boolean }): Readable<T | E>
reset(key): Maybe<T | E> // restore stashed persistent value
extract(key): T | null // synchronous read, ignores errors
delete(key): void
clear(): void
}Options:
get?: (key: string) => Promise<T | E>— per-key fetcherrevalidate?: number— ms before re-fetching (default300_000)stale?: boolean— keep value while revalidating (defaultfalse)persist?: string— storage key for the whole mappermanent?: boolean— don't revalidate persisted entries on startup; combine withrevalidate: Infinityto disable revalidation entirelybind?: Readable<unknown | null>— clear the cache when bound store isnull
The stash flag marks a value as transient: the previously persisted value is remembered and can be restored with reset(key). Useful for optimistic updates.
collection<T>(options)
A list of identifiable items, optionally backed by a values store so individual items can be observed independently.
Items must extend Identifiable:
interface Identifiable {
id: string
}import { collection, values } from 'svas'
const items = values<Todo>({ persist: 'todos' })
const todos = collection<Todo>({
get: () => api.listTodos(),
values: items,
revalidate: 60_000,
stale: true,
persist: 'todos:list',
bind: session
})Interface:
class Collection<T extends Identifiable, E extends Error = Error>
implements Readable<Maybe<T[], E>>
{
subscribe(run): Unsubscriber
add(item: T): void
set(item: T, options?: { add?: boolean }): void
update(id, updater: (item) => T | void, options?): void
delete(id): void
get(id, options?): Readable<Maybe<T, E>> // requires `values`
extract(id): T | null // requires `values`
replace(items: T[]): void
sync(): this
fetch(): Promise<this>
}Options:
get?: () => Promise<T[] | E>— list fetcherrevalidate?: number— ms before re-fetching (default300_000)stale?: boolean— keep list while revalidating (defaultfalse)values?: Values<T, E>— side store for per-item subscriptionspersist?: string— storage key for the listbind?: Readable<unknown | null>— clear on bound store nullification
When a values store is provided, mutations to the collection are mirrored into it, so components subscribed via get(id) update without re-fetching.
reflection<T>(options)
A copy of a collection a server streams, kept in IndexedDB and kept current. It reads the collection once, and from then on what changed in it, from the token the last read ended with. Queries answer from the copy, through indexes declared up front.
import { reflection, expired, ok } from 'svas'
const pots = reflection<Pot>({
name: 'pots',
stream: async (token) => {
const response = await api.stream('/pots/stream/', token)
// the server no longer continues from this token: the copy is read again from the start
if (response.status === 410) return expired
return parts(response) // the application's: the parts of the multipart body, as they arrive
},
get: (id) => api.getPot(id),
indexes: { type: 'type', due: ['type', 'due'] },
bind: session
})
const green = pots.query('due', { gte: ['green', 0], lte: ['green', Infinity] })
const pot = pots.get(id)
const created = await api.createPot(input)
if (ok(created)) await pots.apply(created)
events.on('pots.changed', () => pots.sync())Interface:
class Reflection<T extends Comparable> {
query(index, criteria?, options?: { order?: 'asc' | 'desc', limit?: number }): Readable<Maybe<T[]>>
get(id): Readable<Maybe<T>>
apply(entry: T): Promise<void>
sync(): Promise<Error | null>
empty(): Promise<void>
}Options:
name: string— the IndexedDB database,svas:<name>stream: (token?: string) => Promise<AsyncIterable<StreamPart<T>> | typeof expired>— reads the collection from a token, or from the start without one. It yields{ entry },{ removed }and, last,{ token }, and answersexpiredwhere the server no longer continues from the tokenget?: (id) => Promise<Maybe<T>>— an entry the copy does not holdindexes?: Record<string, string | string[]>— by name: a property, or a list of thembind?: Readable<unknown | null>— deletes the copy when the bound store isnull
The copy is read on the first subscription, and on sync() — call it when something says the
collection changed. A query answers null until the copy holds the whole collection, and from the
copy at once on every later start. criteria is a key of the index, or bounds of one:
{ gt, gte, lt, lte }; id is always an index.
apply(entry) takes the state a write answered without reading it: kept where its VERSION is
higher than the copy's, taken out where it is DELETED.
empty() says the collection is empty, as it is for an account just made — call it on
registration, before anything subscribes: queries answer [], and nothing is read until sync()
or the next start. Once a read has started, it does nothing.
A read is kept whole or not at all: one that ends without a token was cut, and the next sync
reads it again. A token the server no longer continues from drops the copy, which is read again.
One tab reads at a time, and the others take what it read.
Utilities
ok(value)
Type guard narrowing a Maybe<T> to T:
import { ok } from 'svas'
if (ok($user)) {
$user.name // typed as T, excludes null | Error
}ensure(store)
Synchronously reads a store and throws if the value is null or an Error. Use in code paths where the value is known to be resolved.
import { ensure } from 'svas'
const user = ensure(userStore)having(store)
Returns a promise that resolves with the first non-null, non-error value of the store.
import { having } from 'svas'
const user = await having(userStore) // Tawaited(store)
Returns a promise that resolves with the first non-null value of the store, including errors.
import { awaited } from 'svas'
const result = await awaited(userStore) // T | Erroronce(store, condition)
Returns a promise that resolves with the first value satisfying condition. Building block for having and awaited.
import { once } from 'svas'
const ready = await once(status, (s) => s === 'ready')combined(...stores)
Combines multiple Maybe stores into one. Resolves to the tuple of values when all are non-null, to the first encountered Error, or to null while any is still loading.
import { combined } from 'svas'
const both = combined(user, settings)
// $both: [User, Settings] | null | Errorsync(store, item, options?)
Merges a versioned item into a Collection or Value based on VERSION, and optionally deletes when DELETED is set. Useful for applying server events.
interface Comparable {
id: string
VERSION: number
DELETED?: number | null
}import { sync } from 'svas'
sync(todos, incoming) // insert/update if newer
sync(todos, incoming, { delete: false }) // ignore tombstonesAsync
Svelte component for rendering a Maybe store with waiting, error, and awaited snippets.
While the store has no value, Async renders waiting, and nothing where it is not given: what loading looks like is the app's to declare. An error it renders with error, or a default message with a reload button — nothing, with silent.
<script lang="ts">
import { Async } from 'svas'
</script>
<Async store={user}>
{#snippet waiting()}<Spinner />{/snippet}
{#snippet error(e)}<ErrorView {e} />{/snippet}
{#snippet awaited(value)}<Profile {value} />{/snippet}
</Async>Types
import type { Maybe } from 'svas'
type Maybe<T, E extends Error = Error> = null | T | E