Browser storage
Storage is a driver effect. Keep reads/writes out of reducers, init and views. The optional
@gyral/core/storage entry adds no storage code to apps that do not import it. Factories and
imports are safe on the server; browser access starts only when commands run.
localStorage
// src/theme-storage.ts
import { command, define, html } from '@gyral/core';
import { makeLocalStorageDriver, type StorageError, type StorageOutput } from '@gyral/core/storage';
import * as v from 'valibot';
const settings = makeLocalStorageDriver(v.object({ theme: v.string() }), { prefix: 'app:' });
interface State {
readonly theme: string;
readonly error?: StorageError;
}
type Msg =
| { readonly _tag: 'Stored'; readonly result: StorageOutput<{ theme: string }> }
| { readonly _tag: 'Failed'; readonly error: StorageError }
| { readonly _tag: 'Dark' };
export const Theme = define<State, Msg>()('my-storage-theme', {
init: () => ({ theme: 'light' }),
subscriptions: () => [
command(
settings,
{ _tag: 'Watch', key: 'settings' },
{
key: 'settings-watch',
onSuccess: (result) => ({ _tag: 'Stored', result }),
onFailure: (error) => ({ _tag: 'Failed', error }),
},
),
],
intent: { Dark: true },
update: {
Stored: (s, m) =>
m.result._tag === 'StorageChanged' && m.result.entry.found
? { theme: m.result.entry.value.theme }
: s,
Failed: (s, m) => ({ ...s, error: m.error }),
Dark: (s) => [
s,
[
command(
settings,
{
_tag: 'Write',
key: 'settings',
value: { theme: 'dark' },
},
{ key: 'settings-write', onFailure: (error) => ({ _tag: 'Failed', error }) },
),
],
],
},
view: (s, i) =>
html`<p>${s.theme}</p>
<button type="button" data-intent=${i.Dark}>Dark theme</button>`,
});Read { key } returns StorageValue with entry: { found: false } or
{ found: true, value }. Write { key, value }, Remove { key } and Clear return
completion tags. Clear removes only the configured prefix (default empty: all localStorage).
Watch emits an initial snapshot, then changes from driver writes (source: 'driver') and
native events in other tabs ('tab'). Direct same-tab localStorage writes are not observed.
Removal/clear emit absence. A malformed Watch value emits StorageDecodeFailed and leaves
the watch running; Read rejects with StorageDecodeError. Access/quota/security failures
go through StorageAccessError and command onFailure. Handle both watch output variants.
The Standard Schema decodes parsed JSON, including async validation. If it transforms the
input, configure encode(value) to reconstruct its JSON input shape. No transactions or
cross-tab atomicity are promised. Use distinct command keys for watches and writes. Abort
removes listeners and suppresses late decoder results. Tests use fakeDriver(settings)
with el.drivers = { localStorage: fake }, or an isolated storage: () => storageObject.
Initial watches belong in subscriptions: the server's seed hydrates first, then browser
storage updates the model. Keep server-rendered state JSON-compatible.
IndexedDB
Use the separate @gyral/core/indexeddb entry for structured records, object stores,
indexes, migrations and atomic transaction batches. It uses the same command and fake-driver
conventions, with native IndexedDB semantics:
// src/contacts-storage.ts
import { command, type Command } from '@gyral/core';
import {
makeIndexedDBDriver,
type IndexedDBError,
type IndexedDBOutput,
} from '@gyral/core/indexeddb';
import * as v from 'valibot';
const stores = { contacts: v.object({ id: v.string(), name: v.string() }) };
type Contact = v.InferOutput<typeof stores.contacts>;
const contacts = makeIndexedDBDriver(stores, {
database: 'address-book',
version: 1,
upgrade: (db, oldVersion) => {
if (oldVersion < 1) {
const store = db.createObjectStore('contacts', { keyPath: 'id' });
store.createIndex('name', 'name');
}
},
});
type Msg =
| { readonly _tag: 'Database'; readonly result: IndexedDBOutput<typeof stores> }
| { readonly _tag: 'Failed'; readonly error: IndexedDBError };
const handlers = {
onSuccess: (result: IndexedDBOutput<typeof stores>): Msg => ({ _tag: 'Database', result }),
onFailure: (error: IndexedDBError): Msg => ({ _tag: 'Failed', error }),
};
export const readContact = (id: string): Command<Msg> =>
command(contacts, { _tag: 'Read', store: 'contacts', key: id }, handlers);
export const saveContacts = (values: readonly Contact[]): Command<Msg> =>
command(
contacts,
{
_tag: 'Transaction',
operations: values.map(
(value) =>
({
_tag: 'Put',
store: 'contacts',
value,
}) as const,
),
},
{ ...handlers, concurrency: 'queue' },
);
export const findContacts = (name: string): Command<Msg> =>
command(
contacts,
{
_tag: 'Query',
store: 'contacts',
index: 'name',
range: { _tag: 'Only', key: name },
limit: 20,
},
handlers,
);Read returns Record with a found/absent entry. Put returns its actual primary key
(optional input key, or the store's inline/auto-increment key); Delete returns Deleted.
Query returns Records with primary keys and decoded values. Queries require a positive
integer limit, allow direction (next, prev, nextunique, prevunique), and optional
range data: Only/Lower/Upper with key, or Bound with lower/upper. Bounds accept open,
lowerOpen and upperOpen as appropriate. Store names/record values are typed; index names
are strings defined by the application migration and checked by the browser.
A nonempty Transaction batch queues all requests synchronously across its stores and
returns Committed { results } in operation order after the native transaction completes.
Read-only batches use readonly transactions; batches containing Put/Delete use readwrite.
A failure aborts the whole batch. Stored Read/Query values decode after commit, including
async schemas; a decode failure carries committed: true, so earlier batch writes remain.
An optional encode(store, value) reconstructs a transforming schema's stored input shape.
The version and synchronous upgrade(db, oldVersion, newVersion, transaction) callback
belong to the application. Queue migration requests before returning; never await. A blocked
upgrade rejects with IndexedDBBlockedError, aborts any abandoned late upgrade and closes
any late connection. Retry explicitly once older connections close. Migration/access/native
transaction failures use IndexedDBAccessError; invalid query limits/empty batches use
IndexedDBInputError; invalid persisted records use IndexedDBDecodeError.
Abort attempts rollback and suppresses late output; it cannot undo an already committed
transaction. Connections close after each native transaction, before async decoding, and
on versionchange. No resources open on import/factory creation. Start browser reads after
the first render (init commands or subscriptions), and keep SSR model state JSON-compatible.
Tests substitute fakeDriver(contacts) by name (indexeddb by default). No storage watches
or automatic cross-tab synchronization are promised for this driver.