You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/arts/storage/README.md

20 KiB

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 (.current reactive, dispatcher per EngineStorage)
  • 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
  • onError with rich context routed through App.logger when used from active-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 stored v differs from the configured version, 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 no ttlMs.

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

  • defaults accepts T | (() => 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.
  • undefined is rejected. Use null when 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. Set true to 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.root scope — the helper relies on $effect to 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 / removeItem delegate to the inner adapter, then post on the channel.
  • onChange subscribes to the channel; the inner adapter's own onChange is replaced to avoid duplicate delivery (e.g. localStorage's storage event 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 BroadcastChannel does not fire for messages it posted itself.
  • Returns a BroadcastAdapter with a close() method. Channels are bound to the document and release on page unload, so calling close() is optional unless the adapter has a shorter lifetime than the page.
  • SSR-safe: when BroadcastChannel is unavailable, the wrapper proxies the inner adapter; onChange becomes undefined and close() 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.

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.

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.

Powered by TurnKey Linux.