|
|
5 months ago | |
|---|---|---|
| .. | ||
| adapters | 5 months ago | |
| test | 5 months ago | |
| README.md | 5 months ago | |
| active-storage.svelte.ts | 5 months ago | |
| adapter-registry.ts | 5 months ago | |
| consts.ts | 5 months ago | |
| defaults-registry.ts | 5 months ago | |
| diagnostics.ts | 5 months ago | |
| engine-storage.ts | 5 months ago | |
| entry-bus.ts | 5 months ago | |
| entry-runtime.ts | 5 months ago | |
| entry-values.ts | 5 months ago | |
| envelope.ts | 5 months ago | |
| errors.ts | 5 months ago | |
| index.ts | 5 months ago | |
| serializers.ts | 5 months ago | |
| types.ts | 5 months ago | |
README.md
storage
Reactive, type-safe, sync key/value storage for Svelte 5. Pluggable backends (local, session, memory, cookie), version+migrate, TTL, validation via Standard Schema, intra-tab + cross-tab sync, SSR cookie first-class.
import { createActiveStorage, localAdapter, cookieAdapter } from '$storage';
const Storage = createActiveStorage({
adapter: localAdapter,
namespace: 'my-app'
});
const theme = Storage.entry('theme', 'base', {
adapter: cookieAdapter({ path: '/', maxAge: 31_536_000 }),
raw: true
});
theme.current = 'forest'; // → cookie: theme=forest
Why another one
Existing options in the Svelte ecosystem (svelte-persisted-store,
runed/PersistedState) cover the basics — local/session, cross-tab,
serializer hooks. The combination this artifact targets is what none of them
ship together:
- Svelte 5 runes-first (
.currentreactive, dispatcher perEngineStorage) - Pluggable
SyncStorageAdapter(local, session, memory, cookie, custom) - Cookie adapter with first-class SSR support (
fromCookies(event.cookies)) version+migrate(prev, fromVersion)with auto-rewrite- Legacy reads (envelope-less values are
version: 0→ migratable) - Validation via Standard Schema v1 (Sium schemas reuse out of the box)
- Per-entry overrides for adapter, namespace, raw, TTL — mix cookies for some keys, localStorage for others
onErrorwith rich context routed throughApp.loggerwhen used fromactive-app
Architecture
storage/
├── index.ts barrel
├── types.ts SyncStorageAdapter, EngineStorage, ActiveStorage,
│ StorageEntry, ActiveStorageEntry,
│ StorageEntryOptions (discriminated by `raw`)
├── envelope.ts encode/decode { v, d, t? } + legacy detection
├── serializers.ts string, number, boolean, date, set, map, json, autoSelect
├── entry-bus.ts intra-tab + cross-tab dispatcher
├── engine-storage.ts createEngineStorage (sync API, no runes)
├── active-storage.svelte.ts createActiveStorage (`.current` reactive via $state)
├── adapters/
│ ├── local.ts localAdapter (singleton + storage event)
│ ├── session.ts sessionAdapter (singleton, no cross-tab)
│ ├── memory.ts createMemoryAdapter() factory — tests, SSR
│ └── cookie.ts cookieAdapter(opts) client + cookieAdapter.fromCookies(...) server
├── consts.ts
├── errors.ts
└── test/
Alias
alias: {
$storage: 'src/arts/storage';
}
Quick start
import { createActiveStorage, localAdapter } from '$storage';
const Storage = createActiveStorage({
adapter: localAdapter,
namespace: 'my-app'
});
const theme = Storage.entry('theme', 'base');
theme.current = 'forest'; // reactive .current setter
theme.set('forest'); // explicit setter
theme.update((p) => (p === 'base' ? 'forest' : 'base'));
theme.has(); // true
theme.remove(); // delete from storage; .current → 'base'
theme.reset(); // write 'base' back to storage
theme.onChange((next, prev) => {
console.log(`${prev} → ${next}`);
});
theme.dispose(); // detach from bus
// Wipe every entry created against this storage (logout, "reset all").
// Keys not registered through this storage stay untouched.
Storage.clear();
Picking an adapter
| Adapter | Persists | Cross-tab | Cross-request | Use case |
|---|---|---|---|---|
localAdapter |
yes (forever) | yes | yes | typical app prefs |
sessionAdapter |
yes (per tab) | no | no | tab-scoped drafts |
cookieAdapter(opts) |
yes (maxAge) | no | yes | SSR-readable values |
cookieAdapter.fromCookies(cookies) |
yes | n/a | yes | server side +layout.server.ts |
createMemoryAdapter() |
no | no | no | tests, SSR isolation |
Naming convention
localAdapter and sessionAdapter are bare values — they wrap browser
singletons and have no per-instance state. cookieAdapter is technically a
factory (opts?) => SyncStorageAdapter plus a .fromCookies(cookies, opts?)
static for the server variant; it keeps the bare-value name because there is
no per-instance state inside the cookie wrapper (the browser cookie jar is
the singleton store). createMemoryAdapter() is the only create*-prefixed
adapter because it allocates a private Map per call — fresh state per call
is the whole point.
Envelope format
By default every write goes through an envelope:
{ "v": 1, "d": "<serialized data>", "t": 1735660800000 }
v— schema version. On read, if the storedvdiffers from the configuredversion,migrate(prev, fromVersion)runs.d— the value already passed through the entry's serializer. Layers are independent: envelope handles versioning/TTL, serializer handles type.t— absolute expiration timestamp (epoch ms). Omitted when nottlMs.
Raw mode (raw: true)
Some consumers need the stored cell to be a plain value (cookies the server
or other tools read by name, e.g. theme=forest). With raw: true the
serializer output is stored verbatim — no envelope.
raw: true is incompatible by contract with version, ttlMs and
migrate — there is no envelope to keep that metadata. The TypeScript
discriminated union enforces this at compile time. For expiration in raw
mode, use the adapter's own mechanism (cookieAdapter({ maxAge })).
raw + validate is supported.
Legacy reads
A stored value that is not a valid envelope (e.g. forest,
{"theme":"forest"} written by a previous library) is treated as
version: 0. If migrate is defined, it runs with fromVersion = 0. If
not, the read falls back to the default and has() returns false.
Versioning + migration
const cart = Storage.entry(
'cart',
{ items: [], totals: { net: 0 } },
{
version: 2,
migrate: (prev, from) => {
if (from === 1) {
const old = prev as { items?: string[] };
return { items: old.items ?? [], totals: { net: 0 } };
}
return { items: [], totals: { net: 0 } };
}
}
);
After a successful migrate the engine rewrites the migrated value back
to the adapter so subsequent reads skip the migration path. Disable with
writeMigrated: false (rare — read-only sources).
TTL
const draft = Storage.entry('bio-draft', '', { ttlMs: 60_000 });
draft.set('hi');
// 1 minute later → draft.get() === '' && draft.has() === false
Validation
Two forms — both synchronous in v1:
// Plain function
const port = Storage.entry('port', 8080, {
validate: (v) => {
const n = Number(v);
if (!Number.isFinite(n) || n < 1 || n > 65535) throw new Error('invalid port');
return n;
}
});
// Standard Schema (Sium, Zod, Valibot, ...)
import { App } from '$app';
const sium = App.sium;
const Settings = sium.object({
theme: sium.enumOf(['base', 'forest', 'sunset', 'ink'] as const),
density: sium.enumOf(['compact', 'normal', 'comfortable'] as const)
});
const settings = Storage.entry(
'settings',
{ theme: 'base', density: 'normal' },
{
validate: Settings
}
);
Async Standard Schemas throw — they belong to a future AsyncStorageAdapter.
Default value rules
defaultsacceptsT | (() => T). Use the factory form whenever the default is an object/array, so resets and per-call evaluations don't share the same mutable reference.undefinedis rejected. Usenullwhen you need an empty value — the ambiguity between "key missing" and "stored undefined" is not worth carrying.writeDefaults: false(default) keeps the adapter empty until a real write — no contamination on first paint. Settrueto seed.
mergeDefaults
When the schema gains a new top-level field, old stored values miss it.
mergeDefaults fills the gap on read without a migration:
const cfg = Storage.entry('cfg', { a: 0, b: 2, c: 3 }, { mergeDefaults: true });
// stored: {"v":1,"d":"{\"a\":1}"}
// read: { a: 1, b: 2, c: 3 }
Pass a function for custom semantics:
mergeDefaults: (stored, fresh) => ({ ...fresh, ...stored, list: stored.list ?? [] });
The boolean form only merges plain objects — arrays, primitives, Date,
Set, Map and class instances all pass through unchanged. Use the
function form when you need to merge into an array, replace selectively, or
handle a typed instance.
Multiple EngineStorage on the same adapter
Two EngineStorage instances pointing at the same adapter do not
synchronize with each other through the bus — each owns its own dispatcher.
External adapter events (storage event from another tab) reach both
because each subscribed independently, but a set() on one engine does not
notify entries on the other.
This is deliberate. Two engines exist when callers want isolation (e.g. a
test helper building a sandboxed EngineStorage while the page already has
one). If you need shared local-write notification across "engines", use a
single EngineStorage instance and call entry() on it from both sites —
the bus dedupes by (adapter, fullKey).
Per-entry overrides
const Storage = createActiveStorage({
adapter: localAdapter, // app-wide default
namespace: 'my-app'
});
// Cookie just for locale (server reads it on hydration)
const locale = Storage.entry('locale', 'es', {
adapter: cookieAdapter({ path: '/', maxAge: 31_536_000 }),
namespace: false, // bare cookie name
raw: true // 'es' instead of {"v":1,"d":"es"}
});
// SessionStorage just for the draft
const draft = Storage.entry('bio-draft', '', { adapter: sessionAdapter });
// Everything else hits localStorage with namespace 'my-app'
const cart = Storage.entry('cart', { items: [] });
Storage.dynamicEntry — reactive key
When the storage key depends on a runed variable (current user id, active
workspace, route parameter), use dynamicEntry instead of entry. It
re-binds the underlying entry whenever the key changes, disposes the
previous one and migrates any onChange listeners across the swap:
<script lang="ts">
let userId = $state(1);
const profile = App.storage.dynamicEntry(
() => `user-${userId}:profile`,
() => ({ name: '', cart: [] as string[] })
);
// userId = 2 → profile rebinds to 'user-2:profile' and .current
// reflects whatever lives there (or the default).
</script>
<input bind:value={profile.current.name} />
<button onclick={() => userId++}>next user</button>
Constraints:
- Must be called inside a Svelte component or
$effect.rootscope — the helper relies on$effectto react to key changes. - The key getter must read the runed values it depends on. Plain variables do not trigger rebinds.
- Per-entry options (adapter, namespace, raw, version, ...) carry across rebinds — the new entry is built with the same options.
withBroadcast — cross-tab sync without polling
Wrap any SyncStorageAdapter to add cross-tab synchronization through the
native BroadcastChannel API. Zero polling, instant delivery, browser-wide:
import { withBroadcast, localAdapter, cookieAdapter, createActiveStorage } from '$storage';
const Storage = createActiveStorage({
adapter: withBroadcast(localAdapter, { channel: 'my-app' })
});
Useful pairings:
// Cookie that propagates across tabs the moment the value changes —
// browsers do not emit a native event for cookie mutations, broadcast
// fills the gap.
const session = withBroadcast(
cookieAdapter({ path: '/', maxAge: 3600 }),
{ channel: 'my-app:session' }
);
// In-memory adapter shared between two EngineStorage instances (e.g.
// devtools panel + app), kept in sync without touching disk.
const ephemeral = withBroadcast(
createMemoryAdapter(),
{ channel: 'my-app:ephemeral' }
);
Behavior:
setItem/removeItemdelegate to the inner adapter, then post on the channel.onChangesubscribes to the channel; the inner adapter's ownonChangeis replaced to avoid duplicate delivery (e.g. localStorage'sstorageevent would fire alongside the broadcast otherwise). External mutations made by code that does not go through this wrapper are not surfaced — that is the trade for guaranteed dedup.- Same-tab dedup is automatic: a
BroadcastChanneldoes not fire for messages it posted itself. - Returns a
BroadcastAdapterwith aclose()method. Channels are bound to the document and release on page unload, so callingclose()is optional unless the adapter has a shorter lifetime than the page. - SSR-safe: when
BroadcastChannelis unavailable, the wrapper proxies the inner adapter;onChangebecomesundefinedandclose()is a no-op.
Storage.entries()
Snapshot of every live entry registered against this storage:
Storage.entries();
// [
// { key: 'theme', fullKey: 'theme', adapter: 'cookie' },
// { key: 'cart', fullKey: 'app:cart', adapter: 'memory' }
// ]
Pure metadata, no entry handles. Use it for devtools panels, debug pages or audit logs. Disposed entries drop from the snapshot.
Storage.clear()
Wipe every entry created against this EngineStorage from its underlying
adapter. Live entries stay usable — subsequent get() returns the default
and ActiveStorageEntry.current reacts to the change via the bus.
Storage.entry('cart', { items: [] }).set({ items: ['x'] });
Storage.entry('draft', '').set('hi');
Storage.clear();
// Both keys removed from the adapter; live entries fall back to defaults.
Only keys this EngineStorage registered are removed. Values written to the
same adapter from elsewhere (other EngineStorage instances, foreign code)
stay untouched. Use it for "logout" / "reset preferences" flows.
Cross-tab sync
localAdapter subscribes to window.addEventListener('storage', ...). The
event fires only in other tabs — intra-tab sync is handled by an
internal bus per EngineStorage, keyed by adapter + fullKey, so two
entry('theme', 'base') calls on the same backend always see each other's
writes:
const a = Storage.entry('theme', 'base');
const b = Storage.entry('theme', 'base');
a.set('forest');
console.log(b.current); // 'forest'
sessionAdapter, createMemoryAdapter() and cookieAdapter() have no
cross-context channel; their entries still sync intra-tab via the bus. If an
entry overrides adapter, it gets its own bus channel and does not receive
events from another backend using the same key.
The cookie adapter does not poll document.cookie for changes. Browsers
do not emit a native event when a cookie mutates from another tab or from
the server, and a polling loop would burn CPU for a niche use case. If you
need cross-tab sync of a cookie, mirror it to localStorage and listen there,
or hook into a server-sent event your backend already exposes.
Mutación profunda
ActiveStorageEntry.current is not a deep proxy. Assigning to a nested
field does not trigger persistence:
entry.current.foo = 1; // ❌ in-memory only, never written
entry.update((p) => ({ ...p, foo: 1 })); // ✓ writes
entry.current = { ...entry.current, foo: 1 }; // ✓ writes (top-level reassign)
This is the $state proxy boundary, not a storage limitation. We
deliberately avoid wrapping the value in something more complex.
SSR cookie pattern
The cookie adapter is the same artifact running on both sides — only the
factory differs. The client reads/writes document.cookie; the server
proxies SvelteKit's event.cookies. Both implement SyncStorageAdapter so
the rest of the code stays adapter-agnostic.
1. Server load — read on first request
// src/web/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
import { createEngineStorage, cookieAdapter } from '$storage';
export const load: LayoutServerLoad = ({ cookies }) => {
const storage = createEngineStorage({
adapter: cookieAdapter.fromCookies(cookies)
});
// `raw: true` so the cookie value is the literal string ('es' / 'base')
// — no envelope. Other tools (curl, browser devtools, server middlewares)
// can read it without parsing JSON.
const locale = storage.entry('locale', 'es', { raw: true });
const visualTheme = storage.entry('visual-theme', 'base', { raw: true });
return { locale: locale.get(), visualTheme: visualTheme.get() };
};
2. Client root layout — hydrate without flash
<!-- src/web/routes/+layout.svelte -->
<script lang="ts">
import { setContext, onDestroy } from 'svelte';
import { createActiveApp, cookieAdapter, localAdapter } from '$active-app';
import { defineActiveLangs, defineActiveStorage } from '$active-app/services';
import { appLangs } from './langs';
let { data, children } = $props();
const App = createActiveApp({
services: {
langs: defineActiveLangs({ schema: appLangs, defaultLocale: data.locale }),
storage: defineActiveStorage({ adapter: localAdapter, namespace: 'my-app' })
}
});
const visualThemeCookie = App.storage.entry('visual-theme', data.visualTheme, {
adapter: cookieAdapter({ path: '/', sameSite: 'lax', maxAge: 31_536_000 }),
namespace: false,
raw: true
});
// UIX/Eidos shells pass visualThemeCookie.current to ActiveEidos.theme.
setContext('app', App);
onDestroy(() => App.dispose());
</script>
{@render children()}
Server and client agree on language/locale and visual theme on first
render — no hydration mismatch, no one-frame flash. The cookie carries those
values across requests; everything else (cart, drafts, cached settings) lives
in localStorage with the App's namespace. UIX does not persist this through a
presentation shell: the visual value is an input to ActiveEidos.
3. Persisting language changes back to the cookie
createActiveApp does not auto-persist language. Wire it explicitly:
<script lang="ts">
import { getContext } from 'svelte';
import { cookieAdapter } from '$active-app';
import type { ActiveApp, SupportedLocale } from '$active-app';
const App = getContext<ActiveApp>('app');
const languageCookie = App.storage.entry('language', App.prefs.language.get(), {
adapter: cookieAdapter({ path: '/', maxAge: 31_536_000 }),
namespace: false,
raw: true
});
function pickLanguage(next: SupportedLocale): void {
App.prefs.language.set(next);
languageCookie.set(next);
}
</script>
The next request will arrive with language=<next> and +layout.server.ts
will hydrate the App with the same value.
Error handling
onError receives a structured context per failure:
const Storage = createEngineStorage({
onError: ({ key, fullKey, adapter, op, error }) => {
console.error(`[storage] ${op} on ${fullKey} via ${adapter}:`, error);
}
});
Operations: read | write | remove | serialize | deserialize | migrate | validate.
Default handler: diagnostics-only. When used from active-app, the shared
App.logger is injected, so failures land through StorageDiagnostics with
the storage diagnostic context.
Auto-serializers
Detection is top-level only based on the defaults value:
| typeof defaults | Serializer |
|---|---|
string |
stringSerializer (identity) |
number |
numberSerializer |
boolean |
booleanSerializer |
Date instance |
dateSerializer |
Set instance |
setSerializer |
Map instance |
mapSerializer |
| anything else | jsonSerializer |
{ user: { joinedAt: new Date() } } falls through to JSON, which serializes
the date as a string and never restores it. Pass an explicit serializer
for nested types — the heavy-duty option is wrapping devalue or
SuperJSON as a serializer.
Testing
import { createMemoryAdapter, createActiveStorage } from '$storage';
const Storage = createActiveStorage({ adapter: createMemoryAdapter() });
// ... assertions
Storage.dispose();
Each createMemoryAdapter() call returns an isolated Map. Two stores
built in the same test do not bleed.